feat(validation): русская локализация и строгий email с TLD
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m2s
CI / Web (React + Vite) (push) Successful in 39s
Docker Public / Build + push Public (push) Successful in 31s
Docker Web / Build + push Web (push) Successful in 33s
Docker Public / Deploy Public on stage (push) Successful in 11s
Docker Web / Deploy Web on stage (push) Successful in 12s

— Общий модуль `src/lib/validation.ts` в обоих пакетах (public + web):
  validateEmail (требует TLD ≥2 букв), validatePassword (8+, буква+цифра),
  validatePhone (+7/8, 10 цифр), validateRequired, и
  localizeNativeValidation — слушатель invalid/input для setCustomValidity
  на русском (required, typeMismatch, patternMismatch, tooShort и т.д.).

— public SignupForm: noValidate, валидация на onSubmit с русскими ошибками
  под каждым полем; placeholder email→ "name@example.kz", phone→ "+7 700
  123 45 67". Старый HTML5 required+minLength+type=email убран —
  теперь всё через нашу схему.

— web LoginPage: noValidate + локальная проверка email/password перед
  login(); красная подсказка появляется в самом поле.

— web Field/TextInput: useEffect с localizeNativeValidation на ref —
  все остальные админские формы (SuperAdminSetup, SuperAdminOrgCreate,
  EmployeesPage, CounterpartiesPage) автоматически получают русские
  нативные подсказки через общий компонент, без правки каждой страницы.

Acceptance: на /signup/ ввод "name@domain" без TLD блокируется
сообщением «Email должен содержать доменную зону…». Нативные браузерные
подсказки больше не на английском.
This commit is contained in:
nns 2026-04-27 08:29:22 +05:00
parent 1f2cf2a28d
commit ff991a7101
5 changed files with 202 additions and 34 deletions

View file

@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { validateEmail, validatePassword, validatePhone } from '@/lib/validation'
// Админский / API endpoint — переключается через PUBLIC_APP_URL на этапе // Админский / API endpoint — переключается через PUBLIC_APP_URL на этапе
// билда. Дефолт пока — текущий рабочий food-market.zat.kz. // билда. Дефолт пока — текущий рабочий food-market.zat.kz.
@ -12,6 +13,8 @@ interface Props {
defaultWarehouses?: number defaultWarehouses?: number
} }
type FieldErrors = Partial<Record<'email' | 'password' | 'orgName' | 'phone' | 'agree', string>>
export default function SignupForm({ defaultPlan = 'start' }: Props) { export default function SignupForm({ defaultPlan = 'start' }: Props) {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@ -21,14 +24,25 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
const [agree, setAgree] = useState(false) const [agree, setAgree] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const submit = async (e: React.FormEvent) => { const validate = (): FieldErrors => {
e.preventDefault() const e: FieldErrors = {}
if (!agree) { setError('Подтвердите согласие с офертой и политикой ПДн.'); return } const em = validateEmail(email); if (em) e.email = em
if (password.length < 8) { setError('Пароль минимум 8 символов.'); return } const pw = validatePassword(password); if (pw) e.password = pw
if (!orgName.trim()) e.orgName = 'Название магазина обязательно для заполнения'
const ph = validatePhone(phone); if (ph) e.phone = ph
if (!agree) e.agree = 'Подтвердите согласие с офертой и политикой ПДн'
return e
}
const submit = async (ev: React.FormEvent) => {
ev.preventDefault()
const errs = validate()
setFieldErrors(errs)
if (Object.keys(errs).length) return
setError(null); setBusy(true) setError(null); setBusy(true)
try { try {
// 1. Создаём организацию + Owner-Employee на /api/auth/signup.
const res = await fetch(`${API_URL}/api/auth/signup`, { const res = await fetch(`${API_URL}/api/auth/signup`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -38,8 +52,6 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
const j = await res.json().catch(() => ({})) const j = await res.json().catch(() => ({}))
throw new Error(j.error ?? `HTTP ${res.status}`) throw new Error(j.error ?? `HTTP ${res.status}`)
} }
// 2. Получаем JWT через стандартный password-grant — тот же путь, что
// делает админка при обычном логине. Никакого дублирования OpenIddict.
const tokRes = await fetch(`${API_URL}/connect/token`, { const tokRes = await fetch(`${API_URL}/connect/token`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@ -51,7 +63,6 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
}) })
if (!tokRes.ok) throw new Error('Регистрация прошла, но токен не выпущен. Войдите вручную.') if (!tokRes.ok) throw new Error('Регистрация прошла, но токен не выпущен. Войдите вручную.')
const tok = await tokRes.json() as { access_token: string; refresh_token: string } const tok = await tokRes.json() as { access_token: string; refresh_token: string }
// 3. Auth-bridge: передаём токены через URL fragment в админку.
const url = `${APP_URL}/auth-bridge#access=${encodeURIComponent(tok.access_token)}&refresh=${encodeURIComponent(tok.refresh_token)}&welcome=1` const url = `${APP_URL}/auth-bridge#access=${encodeURIComponent(tok.access_token)}&refresh=${encodeURIComponent(tok.refresh_token)}&welcome=1`
window.location.assign(url) window.location.assign(url)
} catch (e) { } catch (e) {
@ -59,26 +70,31 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
} finally { setBusy(false) } } finally { setBusy(false) }
} }
const inputCls = (hasError: boolean) =>
`w-full h-10 rounded-md border px-3 text-sm focus:outline-none focus:ring-2 ${
hasError ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 focus:ring-brand'
}`
return ( return (
<form onSubmit={submit} className="space-y-4"> <form onSubmit={submit} noValidate className="space-y-4">
{error && ( {error && (
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div> <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)} )}
<Field label="Email"> <Field label="Email" error={fieldErrors.email}>
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} <input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
className="w-full h-10 rounded-md border border-slate-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand" autoComplete="email" /> autoComplete="email" placeholder="name@example.kz" className={inputCls(!!fieldErrors.email)} />
</Field> </Field>
<Field label="Пароль" hint="Минимум 8 символов, цифра и заглавная."> <Field label="Пароль" hint="Минимум 8 символов, цифра и заглавная." error={fieldErrors.password}>
<input type="password" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} <input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
className="w-full h-10 rounded-md border border-slate-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand" autoComplete="new-password" /> autoComplete="new-password" className={inputCls(!!fieldErrors.password)} />
</Field> </Field>
<Field label="Название магазина"> <Field label="Название магазина" error={fieldErrors.orgName}>
<input type="text" required value={orgName} onChange={(e) => setOrgName(e.target.value)} placeholder="Например: Магазин «Береке»" <input type="text" value={orgName} onChange={(e) => setOrgName(e.target.value)}
className="w-full h-10 rounded-md border border-slate-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand" /> placeholder="Например: Магазин «Береке»" className={inputCls(!!fieldErrors.orgName)} />
</Field> </Field>
<Field label="Телефон (необязательно)"> <Field label="Телефон (необязательно)" error={fieldErrors.phone}>
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+7 ..." <input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)}
className="w-full h-10 rounded-md border border-slate-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand" /> placeholder="+7 700 123 45 67" className={inputCls(!!fieldErrors.phone)} />
</Field> </Field>
<div> <div>
<div className="text-sm font-medium mb-2">Тариф</div> <div className="text-sm font-medium mb-2">Тариф</div>
@ -96,10 +112,13 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
))} ))}
</div> </div>
</div> </div>
<div>
<label className="flex items-start gap-2 text-sm"> <label className="flex items-start gap-2 text-sm">
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} className="mt-1" /> <input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} className="mt-1" />
<span>Я согласен с <a href="/legal/offer" className="text-brand underline">офертой</a> и <a href="/legal/privacy" className="text-brand underline">политикой обработки ПДн</a>.</span> <span>Я согласен с <a href="/legal/offer" className="text-brand underline">офертой</a> и <a href="/legal/privacy" className="text-brand underline">политикой обработки ПДн</a>.</span>
</label> </label>
{fieldErrors.agree && <p className="text-xs text-red-600 mt-1">{fieldErrors.agree}</p>}
</div>
<button type="submit" disabled={busy} className="w-full px-4 py-3 bg-brand text-white font-semibold rounded-md hover:bg-[#009305] disabled:opacity-60"> <button type="submit" disabled={busy} className="w-full px-4 py-3 bg-brand text-white font-semibold rounded-md hover:bg-[#009305] disabled:opacity-60">
{busy ? 'Регистрация…' : 'Начать бесплатно (90 дней)'} {busy ? 'Регистрация…' : 'Начать бесплатно (90 дней)'}
</button> </button>
@ -108,12 +127,13 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
) )
} }
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { function Field({ label, hint, error, children }: { label: string; hint?: string; error?: string; children: React.ReactNode }) {
return ( return (
<label className="block"> <label className="block">
<span className="text-sm font-medium block mb-1.5">{label}</span> <span className="text-sm font-medium block mb-1.5">{label}</span>
{children} {children}
{hint && <span className="text-xs text-slate-500 block mt-1">{hint}</span>} {error && <span className="text-xs text-red-600 block mt-1">{error}</span>}
{!error && hint && <span className="text-xs text-slate-500 block mt-1">{hint}</span>}
</label> </label>
) )
} }

View file

@ -0,0 +1,63 @@
// Единые правила валидации форм с русскоязычными сообщениями.
// Используется в публичных формах сайта; для админки есть свой парный модуль
// (food-market.web/src/lib/validation.ts), идентичный по логике.
const STRICT_EMAIL = /^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$/
export const emailMsg = {
required: 'Email обязателен',
format: 'Некорректный формат email. Пример: user@example.kz',
noTld: 'Email должен содержать доменную зону (например, .kz, .com)',
}
export function validateEmail(value: string): string | null {
if (!value) return emailMsg.required
if (!/^[^@\s]+@[^@\s]+$/.test(value)) return emailMsg.format
if (!/\.[A-Za-z]{2,}$/.test(value)) return emailMsg.noTld
if (!STRICT_EMAIL.test(value)) return emailMsg.format
return null
}
export function validatePassword(value: string): string | null {
if (!value) return 'Это поле обязательно для заполнения'
if (value.length < 8) return 'Пароль должен быть не менее 8 символов'
if (!/[A-ZА-Я]/.test(value)) return 'Пароль должен содержать хотя бы одну заглавную букву'
if (!/[0-9]/.test(value)) return 'Пароль должен содержать хотя бы одну цифру'
return null
}
export function validatePhone(value: string): string | null {
if (!value) return null // optional
// +7 700 123 45 67 в любом форматировании, либо 8…
const digits = value.replace(/\D/g, '')
if (!/^[78]\d{10}$/.test(digits)) return 'Введите корректный телефон. Пример: +7 700 123 45 67'
return null
}
export function validateRequired(value: unknown, label = 'Это поле'): string | null {
if (value === null || value === undefined) return `${label} обязательно для заполнения`
if (typeof value === 'string' && value.trim() === '') return `${label} обязательно для заполнения`
return null
}
// Подсказки для нативных HTML-валидаторов — на случай форм без JS.
export function localizeNativeValidation(input: HTMLInputElement) {
const update = () => {
const v = input.validity
let msg = ''
if (v.valueMissing) msg = 'Это поле обязательно для заполнения'
else if (v.typeMismatch && input.type === 'email') msg = emailMsg.format
else if (v.typeMismatch && input.type === 'url') msg = 'Введите корректный URL (например, https://example.kz)'
else if (v.typeMismatch) msg = 'Введите корректное значение'
else if (v.patternMismatch) msg = input.title || 'Формат не соответствует требуемому'
else if (v.tooShort) msg = `Минимум ${input.minLength} символов`
else if (v.tooLong) msg = `Максимум ${input.maxLength} символов`
else if (v.rangeUnderflow) msg = `Значение должно быть не меньше ${input.min}`
else if (v.rangeOverflow) msg = `Значение должно быть не больше ${input.max}`
else if (v.badInput) msg = 'Введите корректное значение'
else if (v.stepMismatch) msg = 'Значение не соответствует допустимому шагу'
input.setCustomValidity(msg)
}
input.addEventListener('invalid', update)
input.addEventListener('input', () => input.setCustomValidity(''))
}

View file

@ -6,6 +6,7 @@ import { createPortal } from 'react-dom'
import { ChevronDown } from 'lucide-react' import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
import { localizeNativeValidation } from '@/lib/validation'
interface FieldProps { interface FieldProps {
label: string label: string
@ -27,7 +28,13 @@ export function Field({ label, error, children, className }: FieldProps) {
const inputClass = 'w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60 disabled:bg-slate-50 dark:disabled:bg-slate-800/60' const inputClass = 'w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60 disabled:bg-slate-50 dark:disabled:bg-slate-800/60'
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) { export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
return <input {...props} className={cn(inputClass, props.className)} /> // Подключаем локализацию нативных подсказок валидации (required, type=email,
// pattern и т.д.) — чтобы юзер не видел английских "Please fill out this field".
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
if (ref.current) localizeNativeValidation(ref.current)
}, [])
return <input ref={ref} {...props} className={cn(inputClass, props.className)} />
} }
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) { export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {

View file

@ -0,0 +1,69 @@
// Единые правила валидации форм с русскоязычными сообщениями.
// Парный модуль: food-market.public/src/lib/validation.ts.
const STRICT_EMAIL = /^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$/
export const messages = {
required: 'Это поле обязательно для заполнения',
emailFormat: 'Некорректный формат email. Пример: user@example.kz',
emailNoTld: 'Email должен содержать доменную зону (например, .kz, .com)',
passwordShort: 'Пароль должен быть не менее 8 символов',
passwordNoUpper: 'Пароль должен содержать хотя бы одну заглавную букву',
passwordNoDigit: 'Пароль должен содержать хотя бы одну цифру',
phoneFormat: 'Введите корректный телефон. Пример: +7 700 123 45 67',
urlFormat: 'Введите корректный URL (например, https://example.kz)',
iinBin: 'Введите 12-значный ИИН или БИН',
iik: 'ИИК должен начинаться с KZ и содержать 20 знаков',
generic: 'Введите корректное значение',
}
export function validateEmail(value: string): string | null {
if (!value) return messages.required
if (!/^[^@\s]+@[^@\s]+$/.test(value)) return messages.emailFormat
if (!/\.[A-Za-z]{2,}$/.test(value)) return messages.emailNoTld
if (!STRICT_EMAIL.test(value)) return messages.emailFormat
return null
}
export function validatePassword(value: string): string | null {
if (!value) return messages.required
if (value.length < 8) return messages.passwordShort
if (!/[A-ZА-Я]/.test(value)) return messages.passwordNoUpper
if (!/[0-9]/.test(value)) return messages.passwordNoDigit
return null
}
export function validatePhone(value: string): string | null {
if (!value) return null
const digits = value.replace(/\D/g, '')
if (!/^[78]\d{10}$/.test(digits)) return messages.phoneFormat
return null
}
export function validateRequired(value: unknown): string | null {
if (value === null || value === undefined) return messages.required
if (typeof value === 'string' && value.trim() === '') return messages.required
return null
}
// Подсказки для нативных HTML-валидаторов (когда форма не управляется JS).
export function localizeNativeValidation(input: HTMLInputElement) {
const update = () => {
const v = input.validity
let msg = ''
if (v.valueMissing) msg = messages.required
else if (v.typeMismatch && input.type === 'email') msg = messages.emailFormat
else if (v.typeMismatch && input.type === 'url') msg = messages.urlFormat
else if (v.typeMismatch) msg = messages.generic
else if (v.patternMismatch) msg = input.title || 'Формат не соответствует требуемому'
else if (v.tooShort) msg = `Минимум ${input.minLength} символов`
else if (v.tooLong) msg = `Максимум ${input.maxLength} символов`
else if (v.rangeUnderflow) msg = `Значение должно быть не меньше ${input.min}`
else if (v.rangeOverflow) msg = `Значение должно быть не больше ${input.max}`
else if (v.badInput) msg = messages.generic
else if (v.stepMismatch) msg = 'Значение не соответствует допустимому шагу'
input.setCustomValidity(msg)
}
input.addEventListener('invalid', update)
input.addEventListener('input', () => input.setCustomValidity(''))
}

View file

@ -3,6 +3,7 @@ import { useNavigate, useLocation } from 'react-router-dom'
import { login } from '@/lib/auth' import { login } from '@/lib/auth'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Logo } from '@/components/Logo' import { Logo } from '@/components/Logo'
import { validateEmail, messages } from '@/lib/validation'
interface MeResp { roles: string[] } interface MeResp { roles: string[] }
@ -14,10 +15,16 @@ export function LoginPage() {
const [email, setEmail] = useState('admin@food-market.local') const [email, setEmail] = useState('admin@food-market.local')
const [password, setPassword] = useState('Admin12345!') const [password, setPassword] = useState('Admin12345!')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [emailErr, setEmailErr] = useState<string | null>(null)
const [passwordErr, setPasswordErr] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
const em = validateEmail(email)
const pw = password ? null : messages.required
setEmailErr(em); setPasswordErr(pw)
if (em || pw) return
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
@ -42,6 +49,7 @@ export function LoginPage() {
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4"> <div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
noValidate
className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-5" className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-5"
> >
<div> <div>
@ -54,11 +62,12 @@ export function LoginPage() {
<input <input
type="email" type="email"
autoComplete="username" autoComplete="username"
placeholder="name@example.kz"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => { setEmail(e.target.value); if (emailErr) setEmailErr(null) }}
className="w-full rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)]" className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 ${emailErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`}
required
/> />
{emailErr && <span className="text-xs text-red-600 block mt-1">{emailErr}</span>}
</label> </label>
<label className="block space-y-1.5"> <label className="block space-y-1.5">
@ -67,10 +76,10 @@ export function LoginPage() {
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => { setPassword(e.target.value); if (passwordErr) setPasswordErr(null) }}
className="w-full rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)]" className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 ${passwordErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`}
required
/> />
{passwordErr && <span className="text-xs text-red-600 block mt-1">{passwordErr}</span>}
</label> </label>
{error && ( {error && (