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
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:
parent
1f2cf2a28d
commit
ff991a7101
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
src/food-market.public/src/lib/validation.ts
Normal file
63
src/food-market.public/src/lib/validation.ts
Normal 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(''))
|
||||||
|
}
|
||||||
|
|
@ -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>) {
|
||||||
|
|
|
||||||
69
src/food-market.web/src/lib/validation.ts
Normal file
69
src/food-market.web/src/lib/validation.ts
Normal 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(''))
|
||||||
|
}
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue