Add password letter requirement + labeled strength UI + auto-capitalize name fields
- Reject pure-numeric passwords (must contain ≥1 letter) in RegisterForm, ResetPasswordPage, and backend register/reset-password handlers - Replace bare strength bars with labeled 2×2 grid: ≥8 символов, Содержит букву, Заглавная буква, Цифра - Auto-capitalize first letter of each word in "Название отеля" and "Контактное лицо" fields (autoCapitalize="words" + JS handler) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -117,6 +117,10 @@ const auth: FastifyPluginAsync = async (fastify) => {
|
||||
async (request, reply) => {
|
||||
const { hotelName, address, contact, email, phone, password } = request.body
|
||||
|
||||
if (!/[a-zA-Zа-яА-ЯёЁ]/.test(password)) {
|
||||
return reply.code(400).send({ error: 'Пароль должен содержать хотя бы одну букву' })
|
||||
}
|
||||
|
||||
const { rows: existing } = await db.query(
|
||||
'SELECT id FROM users WHERE email = $1',
|
||||
[email.toLowerCase().trim()],
|
||||
@@ -346,6 +350,11 @@ const auth: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, password } = request.body
|
||||
|
||||
if (!/[a-zA-Zа-яА-ЯёЁ]/.test(password)) {
|
||||
return reply.code(400).send({ error: 'Пароль должен содержать хотя бы одну букву' })
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT id FROM users
|
||||
WHERE reset_token = $1
|
||||
|
||||
@@ -330,6 +330,7 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
|
||||
if (!email.trim()) err.email = 'Введите email'
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) err.email = 'Некорректный email'
|
||||
if (password.length < 8) err.password = 'Минимум 8 символов'
|
||||
else if (!/[a-zA-Zа-яА-ЯёЁ]/.test(password)) err.password = 'Пароль должен содержать хотя бы одну букву'
|
||||
setErrors(err)
|
||||
if (Object.keys(err).length > 0) return
|
||||
setLoading(true)
|
||||
@@ -380,8 +381,9 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
|
||||
type="text"
|
||||
className={cn('input', errors.hotelName && 'border-red-400 focus:ring-red-400')}
|
||||
placeholder="Grand Palace Hotel"
|
||||
autoCapitalize="words"
|
||||
value={hotelName}
|
||||
onChange={e => { setHotelName(e.target.value); setErrors(prev => ({ ...prev, hotelName: '' })) }}
|
||||
onChange={e => { setHotelName(e.target.value.replace(/(?:^|\s)\S/g, c => c.toUpperCase())); setErrors(prev => ({ ...prev, hotelName: '' })) }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -399,8 +401,9 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
|
||||
type="text"
|
||||
className={cn('input', errors.contact && 'border-red-400 focus:ring-red-400')}
|
||||
placeholder="Иванов Иван Иванович"
|
||||
autoCapitalize="words"
|
||||
value={contact}
|
||||
onChange={e => { setContact(e.target.value); setErrors(prev => ({ ...prev, contact: '' })) }}
|
||||
onChange={e => { setContact(e.target.value.replace(/(?:^|\s)\S/g, c => c.toUpperCase())); setErrors(prev => ({ ...prev, contact: '' })) }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -438,9 +441,17 @@ function RegisterForm({ onSwitch }: { onSwitch: () => void }) {
|
||||
</button>
|
||||
</div>
|
||||
{password && (
|
||||
<div className="mt-1.5 flex gap-1">
|
||||
{[password.length >= 8, /[A-Z]/.test(password), /[0-9]/.test(password)].map((ok, i) => (
|
||||
<div key={i} className={cn('h-1 flex-1 rounded-full transition-colors', ok ? 'bg-emerald-500' : 'bg-slate-200 dark:bg-slate-600')} />
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
{[
|
||||
{ ok: password.length >= 8, label: '≥ 8 символов' },
|
||||
{ ok: /[a-zA-Zа-яА-ЯёЁ]/.test(password), label: 'Содержит букву' },
|
||||
{ ok: /[A-ZА-ЯЁ]/.test(password), label: 'Заглавная буква' },
|
||||
{ ok: /[0-9]/.test(password), label: 'Цифра' },
|
||||
].map(({ ok, label }) => (
|
||||
<div key={label} className="flex items-center gap-1.5 text-xs">
|
||||
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0 transition-colors', ok ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600')} />
|
||||
<span className={cn('transition-colors', ok ? 'text-emerald-700 dark:text-emerald-400' : 'text-slate-400 dark:text-slate-500')}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,7 @@ export function ResetPasswordPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (password.length < 8) { setError('Минимум 8 символов'); return }
|
||||
if (!/[a-zA-Zа-яА-ЯёЁ]/.test(password)) { setError('Пароль должен содержать хотя бы одну букву'); return }
|
||||
if (password !== password2) { setError('Пароли не совпадают'); return }
|
||||
setError('')
|
||||
setLoading(true)
|
||||
@@ -100,9 +101,17 @@ export function ResetPasswordPage() {
|
||||
</button>
|
||||
</div>
|
||||
{password && (
|
||||
<div className="mt-1.5 flex gap-1">
|
||||
{[password.length >= 8, /[A-Z]/.test(password), /[0-9]/.test(password)].map((ok, i) => (
|
||||
<div key={i} className={cn('h-1 flex-1 rounded-full transition-colors', ok ? 'bg-emerald-500' : 'bg-slate-200 dark:bg-slate-600')} />
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
{[
|
||||
{ ok: password.length >= 8, label: '≥ 8 символов' },
|
||||
{ ok: /[a-zA-Zа-яА-ЯёЁ]/.test(password), label: 'Содержит букву' },
|
||||
{ ok: /[A-ZА-ЯЁ]/.test(password), label: 'Заглавная буква' },
|
||||
{ ok: /[0-9]/.test(password), label: 'Цифра' },
|
||||
].map(({ ok, label }) => (
|
||||
<div key={label} className="flex items-center gap-1.5 text-xs">
|
||||
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0 transition-colors', ok ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600')} />
|
||||
<span className={cn('transition-colors', ok ? 'text-emerald-700 dark:text-emerald-400' : 'text-slate-400 dark:text-slate-500')}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user