From 4f82ebe17aa7c24e9bc50282156fda115a7dc2c8 Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Tue, 4 Aug 2026 22:20:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D1=83=20=D0=BF=D0=B5=D1=80=D1=81=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BF=D1=80=D0=B8=20=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на публичную страницу регламента на форме регистрации, редактируемый в админке текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не проходил юридическую проверку), версия текста растёт при каждой правке. Факт согласия хранится в users (consent_version, consent_given_at) — второй эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт (выключено) сохраняет поведение существующих инсталляций, у уже зарегистрированных пользователей согласие не запрашивается. --- ...ser_consent_to_personal_data_processing.py | 41 +++++++++ backend/api/admin.py | 5 ++ backend/api/auth.py | 13 ++- backend/core/plugins/config.py | 10 +++ backend/models/user.py | 19 +++- backend/repositories/users.py | 20 ++++- backend/schemas/admin.py | 5 ++ backend/schemas/auth.py | 13 +++ backend/services/auth.py | 28 +++++- backend/services/instance_settings.py | 80 +++++++++++++++++ backend/tests/test_auth.py | 90 ++++++++++++++++++- backend/tests/test_instance_settings.py | 65 ++++++++++++++ frontend/src/App.tsx | 5 ++ frontend/src/api/admin.ts | 9 ++ frontend/src/api/auth.ts | 8 ++ .../src/components/admin/AdminSettingsTab.tsx | 59 +++++++++++- frontend/src/pages/ConsentPolicyPage.tsx | 61 +++++++++++++ frontend/src/pages/RegisterPage.tsx | 45 +++++++++- frontend/src/styles/legal.css | 48 ++++++++++ 19 files changed, 615 insertions(+), 9 deletions(-) create mode 100644 backend/alembic/versions/4d08a44ad153_user_consent_to_personal_data_processing.py create mode 100644 frontend/src/pages/ConsentPolicyPage.tsx create mode 100644 frontend/src/styles/legal.css diff --git a/backend/alembic/versions/4d08a44ad153_user_consent_to_personal_data_processing.py b/backend/alembic/versions/4d08a44ad153_user_consent_to_personal_data_processing.py new file mode 100644 index 0000000..f0a7ffe --- /dev/null +++ b/backend/alembic/versions/4d08a44ad153_user_consent_to_personal_data_processing.py @@ -0,0 +1,41 @@ +"""user consent to personal data processing + +Согласие на обработку персональных данных при регистрации (сессия 30): +- `users.consent_version` — редакция регламента (`instance_settings.consent_policy.version`), + с которой согласился пользователь; +- `users.consent_given_at` — момент согласия. + +Оба поля nullable: у существующих пользователей и у зарегистрированных при +выключенном модуле согласие не запрашивалось — `NULL` трактуется как +«согласие не запрашивалось», вход таким пользователям не блокируется. + +Revision ID: 4d08a44ad153 +Revises: d87681e12784 +Create Date: 2026-08-04 21:41:33.813206 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4d08a44ad153' +down_revision: Union[str, Sequence[str], None] = 'd87681e12784' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column('users', sa.Column('consent_version', sa.Integer(), nullable=True)) + op.add_column( + 'users', sa.Column('consent_given_at', sa.DateTime(timezone=True), nullable=True) + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column('users', 'consent_given_at') + op.drop_column('users', 'consent_version') diff --git a/backend/api/admin.py b/backend/api/admin.py index a3f4a3b..a653610 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -62,6 +62,7 @@ from services.email import EmailSendError, create_email_backend from services.instance_settings import ( InstanceSettingsService, InvalidAiLevelError, + InvalidConsentPolicyError, InvalidContactEmailError, InvalidEmailDomainError, InvalidTimezoneError, @@ -404,6 +405,7 @@ async def update_settings( InvalidTimezoneError, InvalidEmailDomainError, InvalidContactEmailError, + InvalidConsentPolicyError, ) as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc queue_served = await anyio.to_thread.run_sync(transcription_queue_served) @@ -470,6 +472,9 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) - contact_email=cfg.contact_email, publish_quality_cap=cfg.media_limits.publish_quality_cap, stage_max_tiles=cfg.media_limits.stage_max_tiles, + consent_required=cfg.consent_required, + consent_policy_text=cfg.consent_policy_text, + consent_policy_version=cfg.consent_policy_version, ) diff --git a/backend/api/auth.py b/backend/api/auth.py index 39dbba1..52179d7 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -21,6 +21,7 @@ from schemas.auth import ( ) from services.auth import ( AuthService, + ConsentRequiredError, EmailAlreadyRegisteredError, EmailNotVerifiedError, InvalidCredentialsError, @@ -63,7 +64,12 @@ async def registration_options( teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items] 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_domains=email_domains + team_choice_enabled=cfg.registration_team_choice, + teams=teams, + email_domains=email_domains, + consent_required=cfg.consent_required, + consent_text=cfg.consent_policy_text, + consent_version=cfg.consent_policy_version, ) @@ -78,6 +84,7 @@ async def register( name_user=data.name_user, password=data.password, team_id=data.team_id, + consent_accepted=data.consent_accepted, ) except EmailAlreadyRegisteredError as exc: raise HTTPException( @@ -91,6 +98,10 @@ async def register( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_email_domain" ) from exc + except ConsentRequiredError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="consent_required" + ) from exc @router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/core/plugins/config.py b/backend/core/plugins/config.py index d7eaa21..eeb9a9b 100644 --- a/backend/core/plugins/config.py +++ b/backend/core/plugins/config.py @@ -123,3 +123,13 @@ class InstanceConfig(BaseModel): # не только в админке — настройка должна быть на руках у клиента до # публикации трека. media_limits: MediaLimitsConfig = Field(default_factory=MediaLimitsConfig) + # Согласие на обработку персональных данных при регистрации: галочка + # обязательна только при `consent_required=True`, текст/версия — редактируемая + # администратором настройка (дефолт — типовой шаблон, не юридический документ) — + # см. `services/instance_settings.py`. `consent_policy_text`/`_version` + # отдаются публично (`GET /auth/registration-options`) независимо от + # `consent_required`, чтобы страница регламента была осмысленной и при + # выключенном модуле. + consent_required: bool = False + consent_policy_text: str = "" + consent_policy_version: int = 1 diff --git a/backend/models/user.py b/backend/models/user.py index 7aaa077..e17d6e1 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -3,7 +3,17 @@ import uuid from datetime import datetime -from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, String, Text, func, text +from sqlalchemy import ( + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + Integer, + String, + Text, + func, + text, +) from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -35,6 +45,13 @@ class User(Base): # Путь к загруженному аватару (относительно `MEDIA_ROOT`): # `avatars/{user_id}.{ext}`; `NULL` — заглушка с инициалами на фронте. avatar_path: Mapped[str | None] = mapped_column(String(512), nullable=True) + # Согласие на обработку персональных данных при регистрации: редакция + # регламента (`instance_settings.consent_policy.version` на момент + # согласия) и время. `NULL` у обоих — согласие не запрашивалось (модуль + # был выключен либо пользователь зарегистрирован до появления этой + # настройки); вход таким пользователям не блокируется. + consent_version: Mapped[int | None] = mapped_column(Integer, nullable=True) + consent_given_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) diff --git a/backend/repositories/users.py b/backend/repositories/users.py index cfe7103..151b7a1 100644 --- a/backend/repositories/users.py +++ b/backend/repositories/users.py @@ -1,6 +1,7 @@ """Репозиторий доступа к таблице `users`.""" import uuid +from datetime import datetime from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -30,9 +31,24 @@ class UserRepository: name_user: str, password_hash: str, team_id: uuid.UUID | None = None, + consent_version: int | None = None, + consent_given_at: datetime | None = None, ) -> User: - """Создать нового пользователя (role='user', email_verified=False по умолчанию).""" - user = User(email=email, name_user=name_user, password_hash=password_hash, team_id=team_id) + """Создать нового пользователя (role='user', email_verified=False по умолчанию). + + `consent_version`/`consent_given_at` — редакция регламента обработки + персональных данных, с которой согласился пользователь, и время + согласия; `None` у обоих, если согласие не запрашивалось (модуль + выключен) — см. `services.auth.AuthService.register`. + """ + user = User( + email=email, + name_user=name_user, + password_hash=password_hash, + team_id=team_id, + consent_version=consent_version, + consent_given_at=consent_given_at, + ) self._session.add(user) await self._session.flush() return user diff --git a/backend/schemas/admin.py b/backend/schemas/admin.py index ce0d635..e7f4d8f 100644 --- a/backend/schemas/admin.py +++ b/backend/schemas/admin.py @@ -163,6 +163,11 @@ class SettingsOut(BaseModel): # качества публикации и максимум плиток сцены, см. `core/plugins/config.py`. publish_quality_cap: PublishQualityCap stage_max_tiles: StageMaxTiles + # Согласие на обработку персональных данных при регистрации — см. + # `core/plugins/config.py::InstanceConfig`. + consent_required: bool + consent_policy_text: str + consent_policy_version: int class TestEmailIn(BaseModel): diff --git a/backend/schemas/auth.py b/backend/schemas/auth.py index ea387b8..1ccb733 100644 --- a/backend/schemas/auth.py +++ b/backend/schemas/auth.py @@ -11,12 +11,18 @@ class RegisterIn(BaseModel): `team_id` допустим только при включённой настройке инстанса `registration_team_choice` (см. `GET /auth/registration-options`) и существующей команде — иначе `POST /auth/register` вернёт 400. + `consent_accepted` обязан быть `True`, если в настройках инстанса + включено `consent_required` (согласие на обработку персональных + данных) — иначе `POST /auth/register` вернёт 400. Игнорируется, если + настройка выключена (второй эшелон проверки — фронт тоже блокирует + кнопку, но сервер не полагается на это). """ email: EmailStr name_user: str = Field(min_length=1, max_length=255) password: str = Field(min_length=8) team_id: uuid.UUID | None = None + consent_accepted: bool = False class VerifyEmailIn(BaseModel): @@ -111,3 +117,10 @@ class RegistrationOptionsOut(BaseModel): team_choice_enabled: bool teams: list[RegistrationTeamOptionOut] email_domains: list[str] = Field(default_factory=list) + # Согласие на обработку персональных данных: `consent_required` — обязательна + # ли галочка на форме регистрации; `consent_text`/`consent_version` отдаются + # ВСЕГДА, независимо от `consent_required` — той же строкой пользуется + # публичная страница регламента, доступная и при выключенном модуле. + consent_required: bool = False + consent_text: str = "" + consent_version: int = 1 diff --git a/backend/services/auth.py b/backend/services/auth.py index af4c27f..21c31e5 100644 --- a/backend/services/auth.py +++ b/backend/services/auth.py @@ -58,6 +58,15 @@ class InvalidEmailDomainError(Exception): """ +class ConsentRequiredError(Exception): + """Согласие на обработку персональных данных не отмечено. + + Поднимается только при включённой настройке инстанса `consent_required` + (см. `InstanceSettingsService`) — второй эшелон проверки, фронт уже не + даёт отправить форму без галочки, но сервер не полагается на это. + """ + + class InvalidVerificationTokenError(Exception): """Токен подтверждения email не найден, просрочен или уже использован.""" @@ -99,6 +108,7 @@ class AuthService: name_user: str, password: str, team_id: uuid.UUID | None = None, + consent_accepted: bool = False, ) -> User: """Зарегистрировать пользователя и отправить письмо для подтверждения email. @@ -109,7 +119,12 @@ class AuthService: email (`registration_email_domain_enabled`), домен `email` (часть после `@`, без учёта регистра) должен совпадать с одним из эталонных доменов (`registration_email_domains`) — иначе - `InvalidEmailDomainError`. Обе проверки — до создания пользователя. + `InvalidEmailDomainError`. Если включено согласие на обработку + персональных данных (`consent_required`), `consent_accepted` обязан + быть `True` — иначе `ConsentRequiredError`; при принятии согласия + в `User` пишутся `consent_version`/`consent_given_at` (редакция + регламента на момент согласия и время). Все проверки — до создания + пользователя. """ existing = await self._users.get_by_email(email) if existing is not None: @@ -129,11 +144,22 @@ class AuthService: if team is None: raise InvalidTeamSelectionError(team_id) + if cfg.consent_required and not consent_accepted: + raise ConsentRequiredError + + consent_version: int | None = None + consent_given_at: datetime | None = None + if cfg.consent_required and consent_accepted: + consent_version = cfg.consent_policy_version + consent_given_at = datetime.now(UTC) + user = await self._users.create( email=email, name_user=name_user, password_hash=await hash_password(password), team_id=team_id, + consent_version=consent_version, + consent_given_at=consent_given_at, ) reply_to = cfg.contact_email if cfg.contact_email_enabled else None await self._issue_verification_email(user, reply_to=reply_to) diff --git a/backend/services/instance_settings.py b/backend/services/instance_settings.py index 7e8dc4f..53feeaa 100644 --- a/backend/services/instance_settings.py +++ b/backend/services/instance_settings.py @@ -54,6 +54,7 @@ _KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice" _KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain" _KEY_CONTACT_EMAIL = "contact_email" _KEY_MEDIA_LIMITS = "media_limits" +_KEY_CONSENT_POLICY = "consent_policy" BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = ( _KEY_CHAT, @@ -79,6 +80,40 @@ _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "d _DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None} _DEFAULT_MEDIA_LIMITS_VALUE: dict[str, Any] = {"publish_quality_cap": "off", "stage_max_tiles": 25} +DEFAULT_CONSENT_POLICY_TEXT = """Это типовой шаблон для предварительной демонстрации. Текст не проходил проверку юриста и не может использоваться как окончательная редакция без такой проверки. Администратор обязан заменить плейсхолдеры в квадратных скобках и, при необходимости, весь текст — под свою организацию и юрисдикцию. + +1. Оператор персональных данных +Оператором персональных данных, обрабатываемых при использовании сервиса [название сервиса], является: [полное наименование организации], [ОГРН/ИНН], адрес места нахождения: [адрес]. Контакты по вопросам обработки персональных данных: [email], [телефон]. + +2. Правовое основание обработки +Обработка персональных данных осуществляется в соответствии с Конституцией Российской Федерации, Федеральным законом от 27.07.2006 № 152-ФЗ «О персональных данных» и принятыми в соответствии с ним нормативными правовыми актами, на основании согласия субъекта персональных данных (статья 9 Федерального закона № 152-ФЗ). + +3. Состав и цели обработки +При регистрации в сервисе обрабатываются следующие персональные данные: адрес электронной почты, имя и фамилия (или иное указанное пользователем имя), пароль (в виде хеша) [дополнить при необходимости]. +Цели обработки: [указать цели — например: создание учётной записи, идентификация пользователя, обеспечение доступа к видеоконференциям, направление служебных уведомлений]. + +4. Срок обработки и хранения +Персональные данные хранятся в течение [указать срок — например: срока действия учётной записи и установленного законом срока после её удаления] либо до отзыва согласия, если это не противоречит требованиям законодательства. + +5. Действия с персональными данными +В отношении персональных данных совершаются следующие действия: сбор, запись, систематизация, накопление, хранение, уточнение, извлечение, использование, передача (в объёме, необходимом для функционирования сервиса), обезличивание, блокирование, удаление, уничтожение. + +6. Права субъекта персональных данных +Субъект персональных данных вправе получать информацию о том, как обрабатываются его персональные данные, требовать их уточнения, блокирования или уничтожения, а также отозвать согласие на обработку, обратившись по контактам, указанным в разделе 1. + +7. Согласие +Регистрируясь в сервисе, пользователь подтверждает, что ознакомлен с настоящим регламентом и даёт согласие на обработку своих персональных данных на условиях, изложенных выше.""" +"""Дефолтный текст регламента (ключ `consent_policy`) — согласован с оператором +до встраивания в код (сессия 30). Шаблон с плейсхолдерами в квадратных +скобках, без указания конкретной организации — администратор обязан +заменить их под свою организацию перед вводом в эксплуатацию.""" + +_DEFAULT_CONSENT_POLICY_VALUE: dict[str, Any] = { + "enabled": False, + "text": DEFAULT_CONSENT_POLICY_TEXT, + "version": 1, +} + # Простой паттерн доменного имени: минимум один символ, минимум одна точка, # метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса), # без пробелов — валидация после нормализации (strip, «@», lower). @@ -114,6 +149,8 @@ class SettingsUpdateIn(BaseModel): contact_email: str | None = None publish_quality_cap: PublishQualityCap | None = None stage_max_tiles: StageMaxTiles | None = None + consent_required: bool | None = None + consent_policy_text: str | None = None class BootstrapOverrides(BaseModel): @@ -165,6 +202,7 @@ def build_bootstrap_defaults( _KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE), _KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE), _KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE), + _KEY_CONSENT_POLICY: dict(_DEFAULT_CONSENT_POLICY_VALUE), } if overrides is None: return defaults @@ -201,6 +239,11 @@ class InvalidEmailDomainError(ValueError): """ +class InvalidConsentPolicyError(ValueError): + """Попытка включить обязательное согласие при пустом тексте регламента + (`consent_required=True` без непустого `consent_policy_text`).""" + + class InvalidContactEmailError(ValueError): """Некорректная настройка контактного адреса инстанса. @@ -371,6 +414,34 @@ class InstanceSettingsService: cfg.media_limits = MediaLimitsConfig(publish_quality_cap=cap, stage_max_tiles=max_tiles) await self._set(_KEY_MEDIA_LIMITS, cfg.media_limits.model_dump(mode="json")) + if patch.consent_required is not None or patch.consent_policy_text is not None: + consent_required = ( + patch.consent_required if patch.consent_required is not None else cfg.consent_required + ) + consent_text = ( + patch.consent_policy_text.strip() + if patch.consent_policy_text is not None + else cfg.consent_policy_text + ) + if consent_required and not consent_text: + raise InvalidConsentPolicyError( + "нельзя включить обязательное согласие с пустым текстом регламента" + ) + # Версия — счётчик редакций текста, а не хеш/дата: администратору + # проще сослаться на «редакцию №3», чем на хеш, а инкремент (в + # отличие от даты) однозначно фиксирует факт правки даже при + # повторном сохранении одного и того же текста в одну секунду. + consent_version = cfg.consent_policy_version + if consent_text != cfg.consent_policy_text: + consent_version += 1 + cfg.consent_required = consent_required + cfg.consent_policy_text = consent_text + cfg.consent_policy_version = consent_version + await self._set( + _KEY_CONSENT_POLICY, + {"enabled": consent_required, "text": consent_text, "version": consent_version}, + ) + await self._session.commit() return cfg @@ -525,4 +596,13 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig: media_limits=MediaLimitsConfig.model_validate( rows.get(_KEY_MEDIA_LIMITS, _DEFAULT_MEDIA_LIMITS_VALUE) ), + consent_required=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get( + "enabled", False + ), + consent_policy_text=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get( + "text", DEFAULT_CONSENT_POLICY_TEXT + ), + consent_policy_version=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get( + "version", 1 + ), ) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 7fb828f..67b9638 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -40,7 +40,11 @@ async def _reset_registration_gating(db_session: AsyncSession) -> None: `conftest.py`). """ await InstanceSettingsService(db_session).update( - SettingsUpdateIn(registration_team_choice=False, registration_email_domain_enabled=False) + SettingsUpdateIn( + registration_team_choice=False, + registration_email_domain_enabled=False, + consent_required=False, + ) ) await db_session.commit() @@ -495,6 +499,90 @@ async def test_register_no_reply_to_when_contact_email_disabled( assert email_backend.reply_to[-1] is None +async def test_registration_options_returns_consent_fields( + client: httpx.AsyncClient, db_session: AsyncSession +) -> None: + """`consent_text`/`consent_version` отдаются ВСЕГДА (нужны странице регламента), + `consent_required` — по факту настройки инстанса.""" + await InstanceSettingsService(db_session).update( + SettingsUpdateIn(consent_policy_text="Текст регламента для теста") + ) + await db_session.commit() + + response = await client.get("/api/v1/auth/registration-options") + assert response.status_code == 200, response.text + body = response.json() + assert body["consent_required"] is False + assert body["consent_text"] == "Текст регламента для теста" + assert isinstance(body["consent_version"], int) + + +async def test_register_without_consent_when_required_returns_400( + client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend +) -> None: + """Сервер отказывает в регистрации без галочки, даже если фронт её не прислал — + второй эшелон проверки (тот же принцип, что `hand_queue_disabled` в 0.0.28).""" + await InstanceSettingsService(db_session).update( + SettingsUpdateIn(consent_required=True, consent_policy_text="Текст регламента") + ) + await db_session.commit() + + response = await client.post( + "/api/v1/auth/register", + json={"email": "no-consent@example.com", "name_user": "No Consent", "password": "supersecret1"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "consent_required" + + result = await db_session.execute(select(User).where(User.email == "no-consent@example.com")) + assert result.scalar_one_or_none() is None + + +async def test_register_with_consent_when_required_writes_version_and_date( + client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend +) -> None: + """Принятое согласие пишется в БД вместе с редакцией регламента и датой.""" + cfg = await InstanceSettingsService(db_session).update( + SettingsUpdateIn(consent_required=True, consent_policy_text="Текст регламента для приёмки") + ) + await db_session.commit() + + response = await client.post( + "/api/v1/auth/register", + json={ + "email": "with-consent@example.com", + "name_user": "With Consent", + "password": "supersecret1", + "consent_accepted": True, + }, + ) + assert response.status_code == 201, response.text + + result = await db_session.execute(select(User).where(User.email == "with-consent@example.com")) + created = result.scalar_one() + assert created.consent_version == cfg.consent_policy_version + assert created.consent_given_at is not None + + +async def test_register_without_consent_when_module_disabled_succeeds_and_leaves_it_null( + client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend +) -> None: + """Модуль выключен (дефолт `_reset_registration_gating`) — регистрация не требует + галочки, `consent_version`/`consent_given_at` остаются `NULL`.""" + response = await client.post( + "/api/v1/auth/register", + json={"email": "consent-disabled@example.com", "name_user": "Consent Disabled", "password": "supersecret1"}, + ) + assert response.status_code == 201, response.text + + result = await db_session.execute( + select(User).where(User.email == "consent-disabled@example.com") + ) + created = result.scalar_one() + assert created.consent_version is None + assert created.consent_given_at is None + + async def test_login_rehashes_legacy_password( client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend ) -> None: diff --git a/backend/tests/test_instance_settings.py b/backend/tests/test_instance_settings.py index e784919..671b889 100644 --- a/backend/tests/test_instance_settings.py +++ b/backend/tests/test_instance_settings.py @@ -48,6 +48,7 @@ from services.instance_settings import ( BootstrapOverrides, InstanceSettingsService, InvalidAiLevelError, + InvalidConsentPolicyError, InvalidContactEmailError, InvalidEmailDomainError, InvalidTimezoneError, @@ -73,6 +74,7 @@ _MANAGED_KEYS = ( "registration_email_domain", "contact_email", "media_limits", + "consent_policy", ) @@ -130,6 +132,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults( "registration_email_domain", "contact_email", "media_limits", + "consent_policy", } cfg = await service.get() assert cfg.transcriber.provider == "faster_whisper_cpu" @@ -149,6 +152,13 @@ async def test_ensure_bootstrapped_imports_yaml_defaults( # потолку `StageGrid` (5×5). assert cfg.media_limits.publish_quality_cap == "off" assert cfg.media_limits.stage_max_tiles == 25 + # Согласие на обработку персональных данных выключено по умолчанию + # (дефолт сохраняет поведение существующих инсталляций), но дефолтный + # текст-шаблон уже на месте — публичная страница регламента осмысленна + # даже при выключенном модуле. + assert cfg.consent_required is False + assert cfg.consent_policy_text != "" + assert cfg.consent_policy_version == 1 async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits( @@ -351,6 +361,61 @@ async def test_registration_team_choice_toggle( assert reloaded.registration_team_choice is True +async def test_consent_policy_toggle_without_text_change_keeps_version( + db_session: AsyncSession, clean_instance_settings: None +) -> None: + """Включение флага без правки текста не увеличивает версию.""" + service = InstanceSettingsService(db_session) + await service.ensure_bootstrapped(PLUGINS_YAML) + baseline = await service.get() + assert baseline.consent_required is False + + cfg = await service.update(SettingsUpdateIn(consent_required=True)) + + assert cfg.consent_required is True + assert cfg.consent_policy_version == baseline.consent_policy_version + + reloaded = await service.get() + assert reloaded.consent_required is True + assert reloaded.consent_policy_version == baseline.consent_policy_version + + +async def test_consent_policy_text_change_bumps_version( + db_session: AsyncSession, clean_instance_settings: None +) -> None: + """Правка текста регламента увеличивает версию — иначе «версия согласия» в БД бессмысленна.""" + service = InstanceSettingsService(db_session) + await service.ensure_bootstrapped(PLUGINS_YAML) + baseline = await service.get() + + cfg = await service.update(SettingsUpdateIn(consent_policy_text="Новый текст регламента")) + assert cfg.consent_policy_text == "Новый текст регламента" + assert cfg.consent_policy_version == baseline.consent_policy_version + 1 + + # Повторное сохранение ТОГО ЖЕ текста версию больше не двигает. + cfg2 = await service.update(SettingsUpdateIn(consent_policy_text="Новый текст регламента")) + assert cfg2.consent_policy_version == cfg.consent_policy_version + + reloaded = await service.get() + assert reloaded.consent_policy_version == cfg.consent_policy_version + + +async def test_consent_policy_enable_with_empty_text_rejected( + db_session: AsyncSession, clean_instance_settings: None +) -> None: + """Нельзя включить обязательное согласие, если текст регламента пуст.""" + service = InstanceSettingsService(db_session) + await service.ensure_bootstrapped(PLUGINS_YAML) + + with pytest.raises(InvalidConsentPolicyError): + await service.update( + SettingsUpdateIn(consent_required=True, consent_policy_text=" ") + ) + + cfg = await service.get() + assert cfg.consent_required is False + + async def test_registration_email_domain_enable_without_domain_rejected( db_session: AsyncSession, clean_instance_settings: None ) -> None: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8c43416..eb5eb3b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { LoginPage } from '@/pages/LoginPage' import { RegisterPage } from '@/pages/RegisterPage' import { VerifyEmailPage } from '@/pages/VerifyEmailPage' +import { ConsentPolicyPage } from '@/pages/ConsentPolicyPage' import { LobbyPage } from '@/pages/LobbyPage' import { JoinPage } from '@/pages/JoinPage' import { RoomPage } from '@/pages/RoomPage' @@ -19,6 +20,10 @@ function App() { } /> } /> } /> + {/* Публичная страница регламента обработки ПДн — читается до регистрации, + когда пользователя ещё нет; ссылка на неё — рядом с галочкой согласия + на RegisterPage. */} + } /> (data.publish_quality_cap) const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles) + const [consentRequired, setConsentRequired] = useState(data.consent_required) + const [consentPolicyText, setConsentPolicyText] = useState(data.consent_policy_text) const [testEmailTo, setTestEmailTo] = useState('') const [testEmailResult, setTestEmailResult] = useState(null) @@ -92,7 +94,11 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { }, onError: (err: unknown) => { if (err instanceof ApiError && err.status === 400) { - toast.show(errorDetail(err) ?? 'Недоступное значение — проверьте уровень AI, таймзону и домен почты', 'error') + toast.show( + errorDetail(err) ?? + 'Недоступное значение — проверьте уровень AI, таймзону, домен почты и текст регламента', + 'error', + ) } else { toast.show('Не удалось сохранить настройки', 'error') } @@ -156,6 +162,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { } if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles + if (consentRequired !== data.consent_required) payload.consent_required = consentRequired + if (consentPolicyText !== data.consent_policy_text) payload.consent_policy_text = consentPolicyText mutation.mutate(payload) } @@ -478,6 +486,55 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { +
+

Согласие на обработку персональных данных

+

+ Галочка на форме регистрации со ссылкой на регламент (страница{' '} + /legal/personal-data-consent). Факт согласия хранится в БД вместе с + номером редакции текста и датой. +

+ +
+
+
+ Требовать согласие при регистрации + Без отмеченной галочки кнопка регистрации неактивна, сервер тоже откажет +
+ +
+ +
+ +

+

+