Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
196 lines
8.7 KiB
TypeScript
196 lines
8.7 KiB
TypeScript
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>
|
||
)
|
||
}
|