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

@@ -460,7 +460,7 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
display_timezone=cfg.display_timezone,
registration_team_choice=cfg.registration_team_choice,
registration_email_domain_enabled=cfg.registration_email_domain_enabled,
registration_email_domain=cfg.registration_email_domain,
registration_email_domains=cfg.registration_email_domains,
contact_email_enabled=cfg.contact_email_enabled,
contact_email=cfg.contact_email,
)

View File

@@ -53,17 +53,17 @@ async def registration_options(
Список команд отдаётся только при включённой настройке инстанса
`registration_team_choice` — иначе пустой массив (справочник команд не
раскрывается, пока выбор выключен). `email_domain` — эталонный домен при
включённой настройке `registration_email_domain`, иначе `None`.
раскрывается, пока выбор выключен). `email_domains` — эталонные домены при
включённой настройке `registration_email_domain`, иначе пустой список.
"""
cfg = await InstanceSettingsService(session).get()
teams: list[RegistrationTeamOptionOut] = []
if cfg.registration_team_choice:
items, _ = await TeamRepository(session).list_all()
teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items]
email_domain = cfg.registration_email_domain if cfg.registration_email_domain_enabled else None
email_domains = cfg.registration_email_domains if cfg.registration_email_domain_enabled else []
return RegistrationOptionsOut(
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domain=email_domain
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domains=email_domains
)

View File

@@ -77,11 +77,11 @@ class InstanceConfig(BaseModel):
# — см. `services/instance_settings.py`.
registration_team_choice: bool = False
# Верификация регистрирующихся по домену email: при включении
# `POST /auth/register` принимает только
# email с доменом `registration_email_domain` — см.
# `POST /auth/register` принимает только email с доменом из
# `registration_email_domains` (любым из списка) — см.
# `services/instance_settings.py`.
registration_email_domain_enabled: bool = False
registration_email_domain: str | None = None
registration_email_domains: list[str] = Field(default_factory=list)
# Контактный адрес инстанса — подставляется в `Reply-To` исходящих писем
# (сами письма уходят от `no-reply@`, отвечать на них некуда без этого
# адреса) — см. `services/instance_settings.py`, `services/email.py`.

View File

@@ -155,7 +155,7 @@ class SettingsOut(BaseModel):
display_timezone: str
registration_team_choice: bool
registration_email_domain_enabled: bool
registration_email_domain: str | None = None
registration_email_domains: list[str] = Field(default_factory=list)
contact_email_enabled: bool
contact_email: str | None = None

View File

@@ -103,11 +103,11 @@ class RegistrationOptionsOut(BaseModel):
`teams` отдаётся только при `team_choice_enabled=True` — иначе пустой
список (справочник команд не раскрывается, пока выбор выключен).
`email_domain` — эталонный домен при включённой верификации регистрации
`email_domains` — эталонные домены при включённой верификации регистрации
по домену email (настройка инстанса `registration_email_domain`), иначе
`None`.
пустой список.
"""
team_choice_enabled: bool
teams: list[RegistrationTeamOptionOut]
email_domain: str | None = None
email_domains: list[str] = Field(default_factory=list)

View File

@@ -106,9 +106,9 @@ class AuthService:
существует — иначе `InvalidTeamSelectionError` (публичный
эндпоинт, деталей не раскрываем). Если включена верификация домена
email (`registration_email_domain_enabled`), домен `email` (часть
после `@`, без учёта регистра) должен совпадать с эталонным —
иначе `InvalidEmailDomainError`. Обе проверки — до создания
пользователя.
после `@`, без учёта регистра) должен совпадать с одним из
эталонных доменов (`registration_email_domains`) — иначе
`InvalidEmailDomainError`. Обе проверки — до создания пользователя.
"""
existing = await self._users.get_by_email(email)
if existing is not None:
@@ -118,7 +118,7 @@ class AuthService:
if cfg.registration_email_domain_enabled:
email_domain = email.rsplit("@", 1)[-1].lower()
if email_domain != cfg.registration_email_domain:
if email_domain not in cfg.registration_email_domains:
raise InvalidEmailDomainError(email)
if team_id is not None:

View File

@@ -63,7 +63,12 @@ _DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domains": []}
"""Формат значения ключа `registration_email_domain` в БД. До версии с
несколькими доменами хранилась форма `{"enabled": bool, "domain": str|None}`
(один домен) — читающий код (`_extract_email_domains`) понимает обе формы
для обратной совместимости с уже развёрнутыми инстансами; при первом же
`update()` значение переписывается в новую форму (см. `update`)."""
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
@@ -95,7 +100,7 @@ class SettingsUpdateIn(BaseModel):
display_timezone: str | None = None
registration_team_choice: bool | None = None
registration_email_domain_enabled: bool | None = None
registration_email_domain: str | None = None
registration_email_domains: list[str] | None = None
contact_email_enabled: bool | None = None
contact_email: str | None = None
@@ -280,26 +285,28 @@ class InstanceSettingsService:
if (
patch.registration_email_domain_enabled is not None
or patch.registration_email_domain is not None
or patch.registration_email_domains is not None
):
enabled = (
patch.registration_email_domain_enabled
if patch.registration_email_domain_enabled is not None
else cfg.registration_email_domain_enabled
)
raw_domain = (
patch.registration_email_domain
if patch.registration_email_domain is not None
else cfg.registration_email_domain
raw_domains = (
patch.registration_email_domains
if patch.registration_email_domains is not None
else cfg.registration_email_domains
)
domain = _normalize_email_domain(raw_domain) if raw_domain else None
if enabled and domain is None:
domains = _normalize_email_domains(raw_domains)
if enabled and not domains:
raise InvalidEmailDomainError(
"нельзя включить верификацию домена email без указания домена"
"нельзя включить верификацию домена email без указания хотя бы одного домена"
)
cfg.registration_email_domain_enabled = enabled
cfg.registration_email_domain = domain
await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain})
cfg.registration_email_domains = domains
await self._set(
_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domains": domains}
)
if patch.contact_email_enabled is not None or patch.contact_email is not None:
contact_enabled = (
@@ -415,6 +422,32 @@ def _normalize_email_domain(domain: str) -> str:
return normalized
def _normalize_email_domains(domains: list[str]) -> list[str]:
"""Нормализовать список доменов: strip/lower/убрать «@» на каждом (см.
`_normalize_email_domain`), отбросить пустые строки, убрать дубликаты
(с сохранением порядка первого вхождения)."""
normalized: list[str] = []
for raw in domains:
if not raw.strip():
continue
domain = _normalize_email_domain(raw)
if domain not in normalized:
normalized.append(domain)
return normalized
def _extract_email_domains(value: dict[str, Any]) -> list[str]:
"""Достать список доменов из значения ключа `registration_email_domain`,
понимая и текущую форму (`domains: [...]`), и форму до многодоменной
поддержки (`domain: str | None`, один домен) — на проде уже записано
именно старое значение, миграция БД для этого не нужна: следующий же
`update()` перепишет строку в новую форму (см. docstring `update`)."""
if "domains" in value:
return list(value["domains"])
legacy_domain = value.get("domain")
return [legacy_domain] if legacy_domain else []
def _normalize_contact_email(email: str) -> str:
"""Нормализовать контактный email (strip, lower) и провалидировать формат."""
normalized = email.strip().lower()
@@ -449,9 +482,9 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
registration_email_domain_enabled=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("enabled", False),
registration_email_domain=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("domain"),
registration_email_domains=_extract_email_domains(
rows.get(_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE)
),
contact_email_enabled=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get(
"enabled", False
),

View File

@@ -238,7 +238,7 @@ async def test_registration_options_disabled_by_default(
body = response.json()
assert body["team_choice_enabled"] is False
assert body["teams"] == []
assert body["email_domain"] is None
assert body["email_domains"] == []
async def test_registration_options_enabled_returns_teams_sorted_by_name(
@@ -343,29 +343,30 @@ async def test_register_with_unknown_team_id_returns_400(
# --- Верификация регистрирующихся по домену email -------------------------------------
async def test_registration_options_returns_email_domain_when_enabled(
async def test_registration_options_returns_email_domains_when_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""При включённой верификации домена `registration-options` отдаёт эталонный домен."""
"""При включённой верификации домена `registration-options` отдаёт все эталонные домены."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
assert response.json()["email_domain"] == "example.com"
assert response.json()["email_domains"] == ["example.com", "corp.example"]
async def test_registration_options_email_domain_null_when_disabled(
async def test_registration_options_email_domains_empty_when_disabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Выключенная верификация домена — `email_domain` всегда `null`, даже если домен сохранён."""
"""Выключенная верификация домена — `email_domains` всегда пуст, даже если домены сохранены."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True, registration_email_domains=["example.com"]
)
)
await InstanceSettingsService(db_session).update(
@@ -375,7 +376,7 @@ async def test_registration_options_email_domain_null_when_disabled(
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
assert response.json()["email_domain"] is None
assert response.json()["email_domains"] == []
async def test_register_with_foreign_domain_when_verification_enabled_returns_400(
@@ -383,7 +384,8 @@ async def test_register_with_foreign_domain_when_verification_enabled_returns_40
) -> None:
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
@@ -405,7 +407,7 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
) -> None:
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True, registration_email_domains=["example.com"]
)
)
await db_session.commit()
@@ -421,6 +423,29 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
assert response.status_code == 201, response.text
async def test_register_with_second_domain_in_list_succeeds(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Email подходит, если совпадает с ЛЮБЫМ доменом из списка — не только с первым."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "dave@corp.example",
"name_user": "Dave",
"password": "supersecret1",
},
)
assert response.status_code == 201, response.text
async def test_register_any_domain_allowed_when_verification_disabled(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:

View File

@@ -134,7 +134,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
assert cfg.display_timezone == "Europe/Moscow"
assert cfg.registration_team_choice is False
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain is None
assert cfg.registration_email_domains == []
assert cfg.contact_email_enabled is False
assert cfg.contact_email is None
@@ -326,7 +326,7 @@ async def test_registration_team_choice_toggle(
async def test_registration_email_domain_enable_without_domain_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение верификации без домена (ни в патче, ни ранее сохранённого) → 400."""
"""Включение верификации без доменов (ни в патче, ни ранее сохранённых) → 400."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
@@ -335,7 +335,24 @@ async def test_registration_email_domain_enable_without_domain_rejected(
cfg = await service.get()
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain is None
assert cfg.registration_email_domains == []
async def test_registration_email_domain_enable_with_empty_list_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Пустой список доменов при включении верификации — та же ошибка, что и
отсутствие поля (не молчаливое отключение проверки)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidEmailDomainError):
await service.update(
SettingsUpdateIn(registration_email_domain_enabled=True, registration_email_domains=[])
)
cfg = await service.get()
assert cfg.registration_email_domain_enabled is False
async def test_registration_email_domain_rejects_invalid_pattern(
@@ -345,48 +362,106 @@ async def test_registration_email_domain_rejects_invalid_pattern(
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidEmailDomainError):
await service.update(SettingsUpdateIn(registration_email_domain="not a domain"))
await service.update(SettingsUpdateIn(registration_email_domains=["not a domain"]))
cfg = await service.get()
assert cfg.registration_email_domain is None
assert cfg.registration_email_domains == []
async def test_registration_email_domain_normalizes_input(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower)."""
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower); пустые строки
отбрасываются, дубликаты схлопываются."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="@Corp.RU "
registration_email_domain_enabled=True,
registration_email_domains=["@Corp.RU ", "", "corp.ru", "Acme.IO"],
)
)
assert cfg.registration_email_domain_enabled is True
assert cfg.registration_email_domain == "corp.ru"
assert cfg.registration_email_domains == ["corp.ru", "acme.io"]
reloaded = await service.get()
assert reloaded.registration_email_domain == "corp.ru"
assert reloaded.registration_email_domains == ["corp.ru", "acme.io"]
async def test_registration_email_domain_can_be_disabled_keeping_stored_domain(
async def test_registration_email_domain_can_be_disabled_keeping_stored_domains(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Выключение верификации без передачи домена не требует домена и не роняет валидацию."""
"""Выключение верификации без передачи доменов не требует их и не роняет валидацию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="acme.io"
registration_email_domain_enabled=True, registration_email_domains=["acme.io"]
)
)
cfg = await service.update(SettingsUpdateIn(registration_email_domain_enabled=False))
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain == "acme.io"
assert cfg.registration_email_domains == ["acme.io"]
async def test_registration_email_domain_reads_legacy_single_domain_shape(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""До многодоменной поддержки в БД хранилась форма `{"domain": str|None}` (один
домен, без миграции на проде) — чтение должно разворачивать её в список из
одного элемента (см. `_extract_email_domains`)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
stmt = (
pg_insert(InstanceSetting)
.values(
key="registration_email_domain",
value={"enabled": True, "domain": "legacy.example"},
)
.on_conflict_do_update(
index_elements=["key"],
set_={"value": {"enabled": True, "domain": "legacy.example"}},
)
)
await db_session.execute(stmt)
await db_session.commit()
cfg = await service.get()
assert cfg.registration_email_domain_enabled is True
assert cfg.registration_email_domains == ["legacy.example"]
async def test_registration_email_domain_rewrites_legacy_shape_on_update(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Первое же сохранение после чтения старой формы переписывает строку в
новую (`domains: [...]`), а не оставляет legacy `domain` рядом."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
stmt = (
pg_insert(InstanceSetting)
.values(
key="registration_email_domain",
value={"enabled": True, "domain": "legacy.example"},
)
.on_conflict_do_update(
index_elements=["key"],
set_={"value": {"enabled": True, "domain": "legacy.example"}},
)
)
await db_session.execute(stmt)
await db_session.commit()
await service.update(SettingsUpdateIn(registration_email_domains=["new.example"]))
row = await db_session.get(InstanceSetting, "registration_email_domain")
assert row is not None
assert row.value == {"enabled": True, "domains": ["new.example"]}
async def test_contact_email_enable_without_email_rejected(

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>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<input
id="settings-email-domain"
type="text"
placeholder="company.ru"
value={emailDomain}
value={newDomainInput}
disabled={!domainVerificationEnabled}
onChange={(e) => setEmailDomain(e.target.value)}
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('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
}