Files
vidconf/frontend/src/pages/RegisterPage.tsx
Max Ronzhin 25ffd9e678
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
feat(auth): несколько эталонных mail-доменов для верификации регистрации
Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
2026-07-27 20:40:23 +03:00

196 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Clock, Mail, Lock, User, Users } from 'lucide-react'
import { AuthLayout } from '@/components/auth/AuthLayout'
import { getRegistrationOptions, register } from '@/api/auth'
import { ApiError, errorDetail } from '@/api/client'
/** Текст предупреждения о несовпадении домена почты с эталонными. */
function domainMismatchMessage(domains: string[]): string {
const list = domains.map((domain) => `@${domain}`).join(' или ')
return `Укажите рабочую почту — регистрация доступна только для домена ${list}`
}
/**
* Экран регистрации (см. design/mockups/auth.html, блок «регистрация»).
* Поле «Команда» показывается только если выбор команды включён в
* настройках инстанса (`GET /auth/registration-options`, публичный эндпоинт).
* Поле «Рабочая почта» дополнительно сверяется с эталонными доменами
* (`registration-options.email_domains` — подходит совпадение с ЛЮБЫМ),
* если в админке включена верификация по домену — проверка идёт по blur и
* при сабмите, backend при включённой верификации и чужом домене отвечает
* 400 `invalid_email_domain`.
* После успешной регистрации показывает состояние «подтвердите почту»
* (письмо со ссылкой backend в dev-режиме печатает в консоль).
*/
export function RegisterPage() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [teamId, setTeamId] = useState('')
const [error, setError] = useState<string | null>(null)
const [emailDomainError, setEmailDomainError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null)
const { data: registrationOptions } = useQuery({
queryKey: ['auth', 'registration-options'],
queryFn: getRegistrationOptions,
})
const teamChoiceEnabled = registrationOptions?.team_choice_enabled ?? false
const teams = registrationOptions?.teams ?? []
const expectedEmailDomains = registrationOptions?.email_domains ?? []
/** Домен после «@» не совпадает (без учёта регистра) ни с одним эталонным — `null`, если сверять не с чем. */
function checkEmailDomain(value: string): string | null {
if (expectedEmailDomains.length === 0) return null
const atIndex = value.lastIndexOf('@')
if (atIndex === -1) return null
const domain = value.slice(atIndex + 1).trim().toLowerCase()
if (!expectedEmailDomains.some((expected) => domain === expected.toLowerCase())) {
return domainMismatchMessage(expectedEmailDomains)
}
return null
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
const domainError = checkEmailDomain(email)
setEmailDomainError(domainError)
if (domainError) return
setPending(true)
try {
await register({ email, name_user: name, password, team_id: teamChoiceEnabled ? teamId || null : null })
setRegisteredEmail(email)
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setError('Пользователь с таким email уже зарегистрирован')
} else if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_email_domain') {
setEmailDomainError(expectedEmailDomains.length > 0 ? domainMismatchMessage(expectedEmailDomains) : 'Регистрация с этим доменом почты недоступна')
} else {
setError('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
}
} finally {
setPending(false)
}
}
if (registeredEmail) {
return (
<AuthLayout>
<section className="screen-block">
<div className="confirm-card pending">
<div className="confirm-icon">
<Clock className="lucide" aria-hidden="true" />
</div>
<h3>Подтвердите почту</h3>
<p>
Мы отправили письмо со ссылкой на{' '}
<span className="mail-chip">
<Mail className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />
{registeredEmail}
</span>
</p>
<Link to="/login" className="btn btn-secondary btn-block btn-lg">
Перейти ко входу
</Link>
</div>
</section>
</AuthLayout>
)
}
return (
<AuthLayout>
<section className="screen-block">
<div className="tabs" role="tablist" aria-label="Способ авторизации">
<Link to="/login" className="tab" role="tab" aria-selected="false">
Вход
</Link>
<span className="tab is-active" role="tab" aria-selected="true">
Регистрация
</span>
</div>
<h2 className="screen-title">Создать аккаунт</h2>
<form onSubmit={handleSubmit} noValidate>
<div className="field">
<label htmlFor="reg-name">Имя и фамилия</label>
<div className="input-wrap">
<User className="lucide field-icon" aria-hidden="true" />
<input
id="reg-name"
type="text"
required
placeholder="Мария Кузнецова"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<div className={`field${emailDomainError ? ' has-error' : ''}`}>
<label htmlFor="reg-email">Рабочая почта</label>
<div className="input-wrap">
<Mail className="lucide field-icon" aria-hidden="true" />
<input
id="reg-email"
type="email"
autoComplete="email"
required
placeholder="you@company.ru"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={(e) => setEmailDomainError(checkEmailDomain(e.target.value))}
/>
</div>
{emailDomainError && <p className="error-msg">{emailDomainError}</p>}
</div>
{teamChoiceEnabled && (
<div className="field">
<label htmlFor="reg-team">Команда</label>
<div className="input-wrap">
<Users className="lucide field-icon" aria-hidden="true" />
<select id="reg-team" value={teamId} onChange={(e) => setTeamId(e.target.value)}>
<option value="">Иное</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</div>
</div>
)}
<div className={`field${error ? ' has-error' : ''}`}>
<label htmlFor="reg-pass">Пароль</label>
<div className="input-wrap">
<Lock className="lucide field-icon" aria-hidden="true" />
<input
id="reg-pass"
type="password"
autoComplete="new-password"
required
minLength={8}
placeholder="Минимум 8 символов"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error ? (
<p className="error-msg">{error}</p>
) : (
<p className="hint-msg">Минимум 8 символов, буквы и цифры</p>
)}
</div>
<button type="submit" className="btn btn-primary btn-block btn-lg" disabled={pending}>
{pending ? 'Регистрируем…' : 'Зарегистрироваться'}
</button>
</form>
</section>
</AuthLayout>
)
}