From ff991a710106a646aa62ebfe45eb25a3b8e24b1b Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:29:22 +0500 Subject: [PATCH] =?UTF-8?q?feat(validation):=20=D1=80=D1=83=D1=81=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D1=8F=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=B9=20email=20=D1=81=20TLD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit — Общий модуль `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 должен содержать доменную зону…». Нативные браузерные подсказки больше не на английском. --- .../src/components/SignupForm.tsx | 74 ++++++++++++------- src/food-market.public/src/lib/validation.ts | 63 ++++++++++++++++ src/food-market.web/src/components/Field.tsx | 9 ++- src/food-market.web/src/lib/validation.ts | 69 +++++++++++++++++ src/food-market.web/src/pages/LoginPage.tsx | 21 ++++-- 5 files changed, 202 insertions(+), 34 deletions(-) create mode 100644 src/food-market.public/src/lib/validation.ts create mode 100644 src/food-market.web/src/lib/validation.ts diff --git a/src/food-market.public/src/components/SignupForm.tsx b/src/food-market.public/src/components/SignupForm.tsx index 0209e42..611f58b 100644 --- a/src/food-market.public/src/components/SignupForm.tsx +++ b/src/food-market.public/src/components/SignupForm.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { validateEmail, validatePassword, validatePhone } from '@/lib/validation' // Админский / API endpoint — переключается через PUBLIC_APP_URL на этапе // билда. Дефолт пока — текущий рабочий food-market.zat.kz. @@ -12,6 +13,8 @@ interface Props { defaultWarehouses?: number } +type FieldErrors = Partial> + export default function SignupForm({ defaultPlan = 'start' }: Props) { const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -21,14 +24,25 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { const [agree, setAgree] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const [fieldErrors, setFieldErrors] = useState({}) - const submit = async (e: React.FormEvent) => { - e.preventDefault() - if (!agree) { setError('Подтвердите согласие с офертой и политикой ПДн.'); return } - if (password.length < 8) { setError('Пароль минимум 8 символов.'); return } + const validate = (): FieldErrors => { + const e: FieldErrors = {} + const em = validateEmail(email); if (em) e.email = em + 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) try { - // 1. Создаём организацию + Owner-Employee на /api/auth/signup. const res = await fetch(`${API_URL}/api/auth/signup`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -38,8 +52,6 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { const j = await res.json().catch(() => ({})) throw new Error(j.error ?? `HTTP ${res.status}`) } - // 2. Получаем JWT через стандартный password-grant — тот же путь, что - // делает админка при обычном логине. Никакого дублирования OpenIddict. const tokRes = await fetch(`${API_URL}/connect/token`, { method: 'POST', 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('Регистрация прошла, но токен не выпущен. Войдите вручную.') 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` window.location.assign(url) } catch (e) { @@ -59,26 +70,31 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { } 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 ( -
+ {error && (
{error}
)} - - 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" /> + + setEmail(e.target.value)} + autoComplete="email" placeholder="name@example.kz" className={inputCls(!!fieldErrors.email)} /> - - 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" /> + + setPassword(e.target.value)} + autoComplete="new-password" className={inputCls(!!fieldErrors.password)} /> - - setOrgName(e.target.value)} placeholder="Например: Магазин «Береке»" - className="w-full h-10 rounded-md border border-slate-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand" /> + + setOrgName(e.target.value)} + placeholder="Например: Магазин «Береке»" className={inputCls(!!fieldErrors.orgName)} /> - - setPhone(e.target.value)} placeholder="+7 ..." - className="w-full h-10 rounded-md border border-slate-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand" /> + + setPhone(e.target.value)} + placeholder="+7 700 123 45 67" className={inputCls(!!fieldErrors.phone)} />
Тариф
@@ -96,10 +112,13 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { ))}
- +
+ + {fieldErrors.agree &&

{fieldErrors.agree}

} +
@@ -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 ( ) } diff --git a/src/food-market.public/src/lib/validation.ts b/src/food-market.public/src/lib/validation.ts new file mode 100644 index 0000000..a75d1fc --- /dev/null +++ b/src/food-market.public/src/lib/validation.ts @@ -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('')) +} diff --git a/src/food-market.web/src/components/Field.tsx b/src/food-market.web/src/components/Field.tsx index d788f0d..dd6fcd3 100644 --- a/src/food-market.web/src/components/Field.tsx +++ b/src/food-market.web/src/components/Field.tsx @@ -6,6 +6,7 @@ import { createPortal } from 'react-dom' import { ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' import { useOrgSettings } from '@/lib/useOrgSettings' +import { localizeNativeValidation } from '@/lib/validation' interface FieldProps { 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' export function TextInput(props: InputHTMLAttributes) { - return + // Подключаем локализацию нативных подсказок валидации (required, type=email, + // pattern и т.д.) — чтобы юзер не видел английских "Please fill out this field". + const ref = useRef(null) + useEffect(() => { + if (ref.current) localizeNativeValidation(ref.current) + }, []) + return } export function TextArea(props: TextareaHTMLAttributes) { diff --git a/src/food-market.web/src/lib/validation.ts b/src/food-market.web/src/lib/validation.ts new file mode 100644 index 0000000..adaf6b7 --- /dev/null +++ b/src/food-market.web/src/lib/validation.ts @@ -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('')) +} diff --git a/src/food-market.web/src/pages/LoginPage.tsx b/src/food-market.web/src/pages/LoginPage.tsx index 842c0e9..671b1f2 100644 --- a/src/food-market.web/src/pages/LoginPage.tsx +++ b/src/food-market.web/src/pages/LoginPage.tsx @@ -3,6 +3,7 @@ import { useNavigate, useLocation } from 'react-router-dom' import { login } from '@/lib/auth' import { api } from '@/lib/api' import { Logo } from '@/components/Logo' +import { validateEmail, messages } from '@/lib/validation' interface MeResp { roles: string[] } @@ -14,10 +15,16 @@ export function LoginPage() { const [email, setEmail] = useState('admin@food-market.local') const [password, setPassword] = useState('Admin12345!') const [error, setError] = useState(null) + const [emailErr, setEmailErr] = useState(null) + const [passwordErr, setPasswordErr] = useState(null) const [loading, setLoading] = useState(false) async function handleSubmit(e: FormEvent) { e.preventDefault() + const em = validateEmail(email) + const pw = password ? null : messages.required + setEmailErr(em); setPasswordErr(pw) + if (em || pw) return setLoading(true) setError(null) try { @@ -42,6 +49,7 @@ export function LoginPage() {
@@ -54,11 +62,12 @@ export function LoginPage() { setEmail(e.target.value)} - 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)]" - required + onChange={(e) => { setEmail(e.target.value); if (emailErr) setEmailErr(null) }} + 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)]'}`} /> + {emailErr && {emailErr}} {error && (