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

@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage'
import { VerifyEmailPage } from '@/pages/VerifyEmailPage'
import { ConsentPolicyPage } from '@/pages/ConsentPolicyPage'
import { LobbyPage } from '@/pages/LobbyPage'
import { JoinPage } from '@/pages/JoinPage'
import { RoomPage } from '@/pages/RoomPage'
@@ -19,6 +20,10 @@ function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
{/* Публичная страница регламента обработки ПДн — читается до регистрации,
когда пользователя ещё нет; ссылка на неё — рядом с галочкой согласия
на RegisterPage. */}
<Route path="/legal/personal-data-consent" element={<ConsentPolicyPage />} />
<Route
path="/lobby"
element={

View File

@@ -45,6 +45,12 @@ export interface SettingsOut {
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`). */
stage_max_tiles: number
/** Обязательна ли галочка согласия на обработку персональных данных при регистрации. */
consent_required: boolean
/** Текст регламента (редактируемый шаблон, дефолт — типовой образец без юридической силы). */
consent_policy_text: string
/** Номер редакции текста — растёт при каждом изменении `consent_policy_text`. */
consent_policy_version: number
}
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
@@ -65,6 +71,9 @@ export interface SettingsUpdateIn {
contact_email?: string | null
publish_quality_cap?: PublishQualityCap
stage_max_tiles?: number
/** Включение с пустым текстом регламента — backend отвечает 400. */
consent_required?: boolean
consent_policy_text?: string
}
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */

View File

@@ -9,6 +9,8 @@ export interface RegisterPayload {
password: string
/** Выбранная команда — только если выбор команды включён в настройках инстанса. */
team_id?: string | null
/** Согласие на обработку персональных данных — обязано быть `true`, если `consent_required`. */
consent_accepted?: boolean
}
/** Команда, доступная для выбора на экране регистрации. */
@@ -23,6 +25,12 @@ export interface RegistrationOptions {
teams: RegistrationTeamOption[]
/** Эталонные домены почты при включённой верификации (email подходит под любой), иначе пуст. */
email_domains: string[]
/** Обязательна ли галочка согласия на обработку персональных данных на форме регистрации. */
consent_required: boolean
/** Текст регламента — отдаётся всегда, независимо от `consent_required` (нужен и странице регламента). */
consent_text: string
/** Номер редакции текста, с которой согласится пользователь при регистрации. */
consent_version: number
}
export interface CurrentUser {

View File

@@ -81,6 +81,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
const [publishQualityCap, setPublishQualityCap] = useState<PublishQualityCap>(data.publish_quality_cap)
const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles)
const [consentRequired, setConsentRequired] = useState(data.consent_required)
const [consentPolicyText, setConsentPolicyText] = useState(data.consent_policy_text)
const [testEmailTo, setTestEmailTo] = useState('')
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
@@ -92,7 +94,11 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 400) {
toast.show(errorDetail(err) ?? 'Недоступное значение — проверьте уровень AI, таймзону и домен почты', 'error')
toast.show(
errorDetail(err) ??
'Недоступное значение — проверьте уровень AI, таймзону, домен почты и текст регламента',
'error',
)
} else {
toast.show('Не удалось сохранить настройки', 'error')
}
@@ -156,6 +162,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
}
if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap
if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles
if (consentRequired !== data.consent_required) payload.consent_required = consentRequired
if (consentPolicyText !== data.consent_policy_text) payload.consent_policy_text = consentPolicyText
mutation.mutate(payload)
}
@@ -478,6 +486,55 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</div>
</section>
<section className="settings-card">
<h2>Согласие на обработку персональных данных</h2>
<p className="desc">
Галочка на форме регистрации со ссылкой на регламент (страница{' '}
<code>/legal/personal-data-consent</code>). Факт согласия хранится в БД вместе с
номером редакции текста и датой.
</p>
<div className="settings-card-body">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Требовать согласие при регистрации</strong>
<span>Без отмеченной галочки кнопка регистрации неактивна, сервер тоже откажет</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={consentRequired}
onChange={(e) => setConsentRequired(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="field" style={{ marginBottom: 0, marginTop: 'var(--space-4)' }}>
<label htmlFor="settings-consent-text">
Текст регламента редакция {data.consent_policy_version}
</label>
<p className="field-hint" style={{ color: 'var(--color-danger)' }}>
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" /> Дефолтный
текст типовой образец, не проходил проверку юриста. Замените плейсхолдеры в
квадратных скобках (наименование оператора, адрес, контакты, цели и срок
обработки) под свою организацию, прежде чем включать требование согласия.
</p>
<textarea
id="settings-consent-text"
rows={12}
value={consentPolicyText}
onChange={(e) => setConsentPolicyText(e.target.value)}
style={{ width: '100%', fontFamily: 'inherit', resize: 'vertical' }}
/>
<p className="field-hint">
Сохранение изменённого текста автоматически увеличивает номер редакции это
значение фиксируется у каждого пользователя вместе с датой согласия.
</p>
</div>
</div>
</section>
<section className="settings-card">
<h2>Тестовое письмо</h2>
<p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p>

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>

View File

@@ -0,0 +1,48 @@
/*
* VidConf — стили публичной страницы регламента обработки персональных данных.
* Топбар/оболочка — по образцу `.join-shell`/`.join-topbar` (join.css):
* страница публичная (открывается до регистрации, без сессии), поэтому не
* использует `.page-shell`/`ShellTopbar`. Карточка шире, чем `.flow-card`
* (440px рассчитан на форму, не на читаемый текст документа).
*/
.legal-shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--color-bg); }
.legal-topbar { display: flex; align-items: center; justify-content: space-between; padding: var(--space-5) var(--space-8); }
.legal-main {
flex: 1;
display: flex;
justify-content: center;
padding: var(--space-6) var(--space-8) var(--space-16);
}
.legal-card {
width: 100%;
max-width: 760px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-2xl);
padding: var(--space-8);
}
.legal-card h1 { font: var(--text-h1); color: var(--color-ink-900); margin: 0 0 var(--space-2); }
.legal-version {
display: inline-block;
font: var(--text-mono-sm);
color: var(--color-ink-500);
background: var(--color-bg-alt);
border-radius: var(--radius-full);
padding: 2px 12px;
margin-bottom: var(--space-6);
}
.legal-text {
font: var(--text-body);
color: var(--color-ink-700);
white-space: pre-wrap;
line-height: 1.6;
}
.legal-empty { font: var(--text-body); color: var(--color-ink-500); }