diff --git a/backend/api/admin.py b/backend/api/admin.py index 67405db..1fb7d80 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -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, ) diff --git a/backend/api/auth.py b/backend/api/auth.py index 2e5ddf8..39dbba1 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -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 ) diff --git a/backend/core/plugins/config.py b/backend/core/plugins/config.py index 30a4785..efcfc13 100644 --- a/backend/core/plugins/config.py +++ b/backend/core/plugins/config.py @@ -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`. diff --git a/backend/schemas/admin.py b/backend/schemas/admin.py index da9395e..69d16c3 100644 --- a/backend/schemas/admin.py +++ b/backend/schemas/admin.py @@ -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 diff --git a/backend/schemas/auth.py b/backend/schemas/auth.py index adf5144..ea387b8 100644 --- a/backend/schemas/auth.py +++ b/backend/schemas/auth.py @@ -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) diff --git a/backend/services/auth.py b/backend/services/auth.py index 0f117fb..12526cc 100644 --- a/backend/services/auth.py +++ b/backend/services/auth.py @@ -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: diff --git a/backend/services/instance_settings.py b/backend/services/instance_settings.py index 95f4937..f1c3cef 100644 --- a/backend/services/instance_settings.py +++ b/backend/services/instance_settings.py @@ -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 ), diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 1d98b49..c1c75e9 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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: diff --git a/backend/tests/test_instance_settings.py b/backend/tests/test_instance_settings.py index 6870486..61dd2c8 100644 --- a/backend/tests/test_instance_settings.py +++ b/backend/tests/test_instance_settings.py @@ -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( diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 520550b..54806d3 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -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 diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 845556d..3a26e54 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -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 { diff --git a/frontend/src/components/admin/AdminSettingsTab.tsx b/frontend/src/components/admin/AdminSettingsTab.tsx index f5df350..dddd45a 100644 --- a/frontend/src/components/admin/AdminSettingsTab.tsx +++ b/frontend/src/components/admin/AdminSettingsTab.tsx @@ -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 }) {

Эталон mail-домена

-

Домен, с которым сверяется email при регистрации, если включена верификация в «Модулях».

+

Домены, с любым из которых сверяется email при регистрации, если включена верификация в «Модулях».

-
+
+ {emailDomains.length > 0 && ( +
+ {emailDomains.map((domain) => ( + + {domain} + + + ))} +
+ )}
- setEmailDomain(e.target.value)} - /> +
+ setNewDomainInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + addDomain() + } + }} + /> + +
diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index 7f97245..7e276ec 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -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('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова') }