feat(auth): согласие на обработку персональных данных при регистрации
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на
публичную страницу регламента на форме регистрации, редактируемый в админке
текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не
проходил юридическую проверку), версия текста растёт при каждой правке.
Факт согласия хранится в users (consent_version, consent_given_at) — второй
эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт
(выключено) сохраняет поведение существующих инсталляций, у уже
зарегистрированных пользователей согласие не запрашивается.
This commit is contained in:
2026-08-04 22:20:00 +03:00
parent 0e029a2bf8
commit 4f82ebe17a
19 changed files with 615 additions and 9 deletions

View File

@@ -0,0 +1,61 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { getRegistrationOptions } from '@/api/auth'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import '@/styles/legal.css'
/**
* Публичная страница регламента обработки персональных данных
* (`/legal/personal-data-consent`) — ссылка рядом с галочкой согласия на
* форме регистрации (`RegisterPage`). Текст и номер редакции берутся из
* того же публичного `GET /auth/registration-options`, которым пользуется
* форма регистрации — отдельного эндпоинта под это специально не заводили.
*
* Страница доступна ВСЕГДА, независимо от `consent_required`: если модуль
* выключен, регламент просто не обязателен для регистрации, но ссылка на
* него не должна вести в никуда — администратор мог оставить текст
* заполненным про запас или для внешней ссылки.
*/
export function ConsentPolicyPage() {
const { data, isLoading } = useQuery({
queryKey: ['auth', 'registration-options'],
queryFn: getRegistrationOptions,
})
return (
<div className="legal-shell">
<header className="legal-topbar">
<Link to="/lobby" className="brand-mark">
<LogoMark /> VidConf
</Link>
<ThemeToggle />
</header>
<main className="legal-main">
<article className="legal-card">
<h1>Регламент обработки персональных данных</h1>
{isLoading && <p className="legal-empty">Загрузка</p>}
{!isLoading && data && data.consent_text.trim() && (
<>
<span className="legal-version">Редакция {data.consent_version}</span>
<div className="legal-text">{data.consent_text}</div>
</>
)}
{!isLoading && data && !data.consent_text.trim() && (
<p className="legal-empty">
Регламент обработки персональных данных для этого инстанса ещё не заполнен
администратором.
</p>
)}
</article>
</main>
<AppFooter />
</div>
)
}

View File

@@ -6,6 +6,8 @@ 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(' или ')
@@ -23,12 +25,19 @@ function domainMismatchMessage(domains: string[]): string {
* 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)
@@ -41,6 +50,7 @@ export function RegisterPage() {
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 {
@@ -61,16 +71,25 @@ export function RegisterPage() {
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 })
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('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
}
@@ -185,7 +204,29 @@ export function RegisterPage() {
<p className="hint-msg">Минимум 8 символов, буквы и цифры</p>
)}
</div>
<button type="submit" className="btn btn-primary btn-block btn-lg" disabled={pending}>
{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>