feat(auth): согласие на обработку персональных данных при регистрации
Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на публичную страницу регламента на форме регистрации, редактируемый в админке текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не проходил юридическую проверку), версия текста растёт при каждой правке. Факт согласия хранится в users (consent_version, consent_given_at) — второй эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт (выключено) сохраняет поведение существующих инсталляций, у уже зарегистрированных пользователей согласие не запрашивается.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user