Files
vidconf/frontend/src/pages/RegisterPage.tsx
Max Ronzhin 4f82ebe17a
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
feat(auth): согласие на обработку персональных данных при регистрации
Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на
публичную страницу регламента на форме регистрации, редактируемый в админке
текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не
проходил юридическую проверку), версия текста растёт при каждой правке.
Факт согласия хранится в users (consent_version, consent_given_at) — второй
эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт
(выключено) сохраняет поведение существующих инсталляций, у уже
зарегистрированных пользователей согласие не запрашивается.
2026-08-04 22:20:00 +03:00

237 lines
11 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'
const CONSENT_POLICY_PATH = '/legal/personal-data-consent'
/** Текст предупреждения о несовпадении домена почты с эталонными. */
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-режиме печатает в консоль).
* Галочка согласия на обработку персональных данных (`consent_required`)
* показывается только при включённой настройке инстанса; ссылка рядом
* ведёт на публичную страницу регламента (`ConsentPolicyPage`), которая
* берёт тот же текст/версию из `registration-options`. Кнопка отправки
* заблокирована, пока галочка не отмечена, — сервер отдельно перепроверяет
* это же условие (`POST /auth/register` отвечает 400 `consent_required`).
*/
export function RegisterPage() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [teamId, setTeamId] = useState('')
const [consentAccepted, setConsentAccepted] = useState(false)
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 ?? []
const consentRequired = registrationOptions?.consent_required ?? false
/** Домен после «@» не совпадает (без учёта регистра) ни с одним эталонным — `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
if (consentRequired && !consentAccepted) return
setPending(true)
try {
await register({
email,
name_user: name,
password,
team_id: teamChoiceEnabled ? teamId || null : null,
consent_accepted: consentAccepted,
})
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 if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'consent_required') {
setError('Отметьте согласие на обработку персональных данных')
} 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>
{consentRequired && (
<label className="checkbox-row" style={{ marginBottom: 'var(--space-4)' }}>
<input
type="checkbox"
checked={consentAccepted}
onChange={(e) => setConsentAccepted(e.target.checked)}
/>
Согласен(на) на{' '}
<Link
to={CONSENT_POLICY_PATH}
target="_blank"
rel="noopener"
style={{ fontWeight: 700, textDecoration: 'underline' }}
>
обработку персональных данных
</Link>
</label>
)}
<button
type="submit"
className="btn btn-primary btn-block btn-lg"
disabled={pending || (consentRequired && !consentAccepted)}
>
{pending ? 'Регистрируем…' : 'Зарегистрироваться'}
</button>
</form>
</section>
</AuthLayout>
)
}