feat(auth): несколько эталонных mail-доменов для верификации регистрации
Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user