feat(auth): несколько эталонных mail-доменов для верификации регистрации
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
This commit is contained in:
2026-07-27 20:40:23 +03:00
parent e55a6aebe7
commit 25ffd9e678
13 changed files with 273 additions and 89 deletions

View File

@@ -32,8 +32,9 @@ export interface SettingsOut {
registration_team_choice: boolean
/** Включена ли верификация регистрации по домену корпоративной почты. */
registration_email_domain_enabled: boolean
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
registration_email_domain: string | null
/** Эталонные домены для верификации (напр. `['company.ru']`) — email подходит, если
* совпадает с любым из списка; пуст, если верификация выключена. */
registration_email_domains: string[]
/** Включён ли контактный адрес инстанса (подставляется в `Reply-To` исходящих писем). */
contact_email_enabled: boolean
/** Контактный адрес — `null`, если не задан/выключен. */
@@ -49,9 +50,9 @@ export interface SettingsUpdateIn {
summary_recipients?: SummaryRecipientsMode
display_timezone?: string
registration_team_choice?: boolean
/** Включение без домена или невалидный домен — backend отвечает 400. */
/** Включение с пустым списком или невалидным доменом — backend отвечает 400. */
registration_email_domain_enabled?: boolean
registration_email_domain?: string | null
registration_email_domains?: string[]
/** Включение без email или невалидный email — backend отвечает 400. */
contact_email_enabled?: boolean
contact_email?: string | null

View File

@@ -21,8 +21,8 @@ export interface RegistrationTeamOption {
export interface RegistrationOptions {
team_choice_enabled: boolean
teams: RegistrationTeamOption[]
/** Эталонный домен почты при включённой верификации, иначе `null`. */
email_domain: string | null
/** Эталонные домены почты при включённой верификации (email подходит под любой), иначе пуст. */
email_domains: string[]
}
export interface CurrentUser {

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { AlertTriangle, X } from 'lucide-react'
import {
getAdminSettings,
sendTestEmail,
@@ -60,7 +60,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const [timezone, setTimezone] = useState(data.display_timezone)
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
const [emailDomains, setEmailDomains] = useState(data.registration_email_domains)
const [newDomainInput, setNewDomainInput] = useState('')
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
const [testEmailTo, setTestEmailTo] = useState('')
@@ -94,6 +95,20 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
},
})
function addDomain() {
const domain = newDomainInput.trim().toLowerCase().replace(/^@/, '')
if (!domain || emailDomains.includes(domain)) {
setNewDomainInput('')
return
}
setEmailDomains([...emailDomains, domain])
setNewDomainInput('')
}
function removeDomain(domain: string) {
setEmailDomains(emailDomains.filter((d) => d !== domain))
}
function handleSave() {
// Отправляем только реально изменённые поля (`SettingsUpdateIn` — набор
// опциональных полей именно для этого): иначе, например, ai_level
@@ -111,9 +126,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
if (domainVerificationEnabled !== data.registration_email_domain_enabled) {
payload.registration_email_domain_enabled = domainVerificationEnabled
}
const trimmedDomain = emailDomain.trim() || null
if (trimmedDomain !== (data.registration_email_domain ?? null)) {
payload.registration_email_domain = trimmedDomain
if (JSON.stringify(emailDomains) !== JSON.stringify(data.registration_email_domains)) {
payload.registration_email_domains = emailDomains
}
if (contactEmailEnabled !== data.contact_email_enabled) {
payload.contact_email_enabled = contactEmailEnabled
@@ -245,19 +259,53 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
<section className="settings-card">
<h2>Эталон mail-домена</h2>
<p className="desc">Домен, с которым сверяется email при регистрации, если включена верификация в «Модулях».</p>
<p className="desc">Домены, с любым из которых сверяется email при регистрации, если включена верификация в «Модулях».</p>
<div className="settings-card-body settings-card-body--center">
<div className="settings-card-body">
{emailDomains.length > 0 && (
<div className="participants-chips">
{emailDomains.map((domain) => (
<span className="participant-chip" key={domain}>
{domain}
<button
type="button"
className="participant-chip-remove"
aria-label={`Убрать домен: ${domain}`}
onClick={() => removeDomain(domain)}
disabled={!domainVerificationEnabled}
>
<X style={{ width: 12, height: 12 }} aria-hidden="true" />
</button>
</span>
))}
</div>
)}
<div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="settings-email-domain">Домен корпоративной почты</label>
<input
id="settings-email-domain"
type="text"
placeholder="company.ru"
value={emailDomain}
disabled={!domainVerificationEnabled}
onChange={(e) => setEmailDomain(e.target.value)}
/>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<input
id="settings-email-domain"
type="text"
placeholder="company.ru"
value={newDomainInput}
disabled={!domainVerificationEnabled}
onChange={(e) => setNewDomainInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addDomain()
}
}}
/>
<button
type="button"
className="btn btn-secondary"
disabled={!domainVerificationEnabled || !newDomainInput.trim()}
onClick={addDomain}
>
Добавить
</button>
</div>
</div>
</div>
</section>

View File

@@ -6,19 +6,21 @@ import { AuthLayout } from '@/components/auth/AuthLayout'
import { getRegistrationOptions, register } from '@/api/auth'
import { ApiError, errorDetail } from '@/api/client'
/** Текст предупреждения о несовпадении домена почты с эталонным. */
function domainMismatchMessage(domain: string): string {
return `Укажите рабочую почту — регистрация доступна только для домена @${domain}`
/** Текст предупреждения о несовпадении домена почты с эталонными. */
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_domain`), если в админке включена
* верификация по домену — проверка идёт по blur и при сабмите, backend
* при включённой верификации и чужом домене отвечает 400 `invalid_email_domain`.
* Поле «Рабочая почта» дополнительно сверяется с эталонными доменами
* (`registration-options.email_domains` — подходит совпадение с ЛЮБЫМ),
* если в админке включена верификация по домену — проверка идёт по blur и
* при сабмите, backend при включённой верификации и чужом домене отвечает
* 400 `invalid_email_domain`.
* После успешной регистрации показывает состояние «подтвердите почту»
* (письмо со ссылкой backend в dev-режиме печатает в консоль).
*/
@@ -38,16 +40,16 @@ export function RegisterPage() {
})
const teamChoiceEnabled = registrationOptions?.team_choice_enabled ?? false
const teams = registrationOptions?.teams ?? []
const expectedEmailDomain = registrationOptions?.email_domain ?? null
const expectedEmailDomains = registrationOptions?.email_domains ?? []
/** Домен после «@» не совпадает (без учёта регистра) с эталонным — `null`, если сверять не с чем. */
/** Домен после «@» не совпадает (без учёта регистра) ни с одним эталонным — `null`, если сверять не с чем. */
function checkEmailDomain(value: string): string | null {
if (!expectedEmailDomain) return 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 (domain !== expectedEmailDomain.toLowerCase()) {
return domainMismatchMessage(expectedEmailDomain)
if (!expectedEmailDomains.some((expected) => domain === expected.toLowerCase())) {
return domainMismatchMessage(expectedEmailDomains)
}
return null
}
@@ -68,7 +70,7 @@ export function RegisterPage() {
if (err instanceof ApiError && err.status === 409) {
setError('Пользователь с таким email уже зарегистрирован')
} else if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_email_domain') {
setEmailDomainError(expectedEmailDomain ? domainMismatchMessage(expectedEmailDomain) : 'Регистрация с этим доменом почты недоступна')
setEmailDomainError(expectedEmailDomains.length > 0 ? domainMismatchMessage(expectedEmailDomains) : 'Регистрация с этим доменом почты недоступна')
} else {
setError('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
}