8 Commits

Author SHA1 Message Date
e25b8c28de release: версия 0.0.30
Some checks are pending
CI / backend (push) Waiting to run
CI / frontend (push) Waiting to run
2026-08-04 22:21:36 +03:00
4f82ebe17a feat(auth): согласие на обработку персональных данных при регистрации
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на
публичную страницу регламента на форме регистрации, редактируемый в админке
текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не
проходил юридическую проверку), версия текста растёт при каждой правке.
Факт согласия хранится в users (consent_version, consent_given_at) — второй
эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт
(выключено) сохраняет поведение существующих инсталляций, у уже
зарегистрированных пользователей согласие не запрашивается.
2026-08-04 22:20:00 +03:00
0e029a2bf8 release: версия 0.0.29
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 21:13:49 +03:00
f89bf1ad64 feat(room): кнопка демонстрации экрана в мини-окне
В Document PiP (Chrome/Edge) своего тулбара нет, и начать демонстрацию,
не свернув мини-окно, было нельзя. Кнопка сделана по образцу кнопки
микрофона, добавленной в 0.0.15: тот же `useTrackToggle` через
`RoomContext`, поэтому она и кнопка основного тулбара — два вида одного
состояния и рассинхрону взяться неоткуда.

Главный вопрос задачи — пустит ли платформа `getDisplayMedia()`, вызванный
из кода основного окна по клику в ДРУГОМ окне. Замер в Chrome 150: после
клика в PiP `navigator.userActivation.isActive === true` в обоих окнах,
активация доезжает до опенера, вызов проходит, системный пикер выбора
экрана открывается отдельным окном поверх всего, а не прячется за
заглушкой основного окна.

Опции захвата (`SCREEN_SHARE_CAPTURE_OPTIONS`) вынесены из `RoomToolbar` в
`lib/screenShareOptions.ts`: кнопок демонстрации теперь две, и разойдись
они хотя бы в `audio`, демонстрация получалась бы разной в зависимости от
того, откуда её запустили.

Фокус в мини-окне не менялся: своя демонстрация показывается по тем же
правилам `pickStageFocus`, что и в основном окне.

В Safari мини-окно — нативный video-PiP без собственного DOM, кнопке там
негде жить; в Firefox мини-окна нет вовсе. Это ограничение платформы.
2026-08-04 21:13:27 +03:00
aee76329c4 release: версия 0.0.28 2026-08-04 18:29:22 +03:00
10a3f8b3b4 feat(room): очередь поднятых рук видна всем + отключаемый модуль
Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь
видит любой участник, но опустить чужую руку по-прежнему может только
организатор (сервер это уже проверял, менял только фронт). Кнопка
«Опустить» показывается у записи, только если это своя рука или
пользователь — организатор.

Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый
в админке (instance_settings.hand_queue, дефолт enabled=true, как у
chat_enabled). Настройка едет участнику в JoinOut ещё до входа в
комнату; выключенный модуль гасит кнопки и на фронте, и на бэке —
raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если
модуль выключен, даже если у клиента на руках старый JoinOut.
2026-08-04 18:27:14 +03:00
65fbcf952c release: версия 0.0.27
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 02:52:49 +03:00
b44652d6a6 fix(room): разрыв связи выбрасывал участника в лобби вместо возврата в конференцию
Телефон с погасшим экраном (и просто свёрнутый браузер) выпадал из
конференции: Chrome срезает фоновой вкладке ресурсы, ICE перестаёт
отвечать, и LiveKit закрывает участника через 5 с после потери
соединения. Замерено на проде: 37 с после блокировки экрана, 23 с
после сворачивания браузера. Восстановить сессию после этого нельзя
(сервер отвечает "could not restart participant") — нужен полный
повторный вход, и livekit-client его пытается сделать сам, но его
бюджет повторов в фоновой вкладке успевает сгореть. Тогда приходило
событие Disconnected, и страница уводила пользователя в лобби.

Теперь непреднамеренный разрыв не уводит со страницы, а сбрасывает
joinState — дальше работает уже написанный путь авто-перезахода:
резолв конференции, свежий токен, вход заново. Намеренный выход
отличается по флагу от кнопки "Выйти", а не по коду причины: причина
CLIENT_INITIATED приходит и от кнопки, и от самого livekit-client,
который при заморозке вкладки (событие freeze) вызывает disconnect()
сам — и эта его подписка не отключается опцией disconnectOnPageLeave.

Разрывы, после которых возвращаться нельзя (выгнал организатор,
конференция закрыта, вход той же личностью с другого устройства),
уводят в лобби как раньше. От бесконечного цикла "вошёл — сразу
выбросило" защищает лимит в 5 перезаходов подряд; соединение,
прожившее дольше 30 с, счётчик обнуляет.
2026-08-04 02:52:22 +03:00
40 changed files with 1246 additions and 113 deletions

View File

@@ -122,7 +122,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой # install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно. # установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.26 VIDCONF_VERSION=0.0.30
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного # --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг, # `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,113 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/). проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.30] — 2026-08-04
Согласие на обработку персональных данных при регистрации + отключаемый модуль.
### Добавлено
- На форме регистрации — галочка согласия на обработку персональных данных
со ссылкой на публичную страницу регламента (`/legal/personal-data-consent`).
Кнопка регистрации неактивна, пока галочка не отмечена; сервер тоже
отказывает без согласия (`POST /auth/register` → 400 `consent_required`,
второй эшелон проверки — тот же принцип, что у `hand_queue_disabled`).
- Текст регламента — настройка инстанса, редактируемая в админке
(вкладка «Настройки» → карточка «Согласие на обработку персональных
данных»): текстовое поле + тумблер «требовать согласие при регистрации».
Дефолтный текст — типовой шаблон с плейсхолдерами под организацию
(наименование оператора, адрес, контакты, цели и срок обработки),
**не проходил юридическую проверку** — в карточке администратора
об этом явное предупреждение.
- Номер редакции текста растёт автоматически при каждой правке —
у каждого пользователя, давшего согласие, в БД фиксируется и версия
документа, и дата согласия (`users.consent_version`, `consent_given_at`).
- Модуль отключаем (`instance_settings.consent_policy`), по умолчанию
выключен — поведение существующих инсталляций не меняется. У уже
зарегистрированных пользователей согласие не запрашивалось и не
запрашивается задним числом, вход не блокируется.
### Технические детали
- Миграция Alembic добавляет `users.consent_version`/`consent_given_at`
(nullable — `NULL` означает «согласие не запрашивалось»).
- Публичный `GET /auth/registration-options` (уже существующий, без нового
эндпоинта) дополнен полями `consent_required`/`consent_text`/`consent_version`
тем же ответом пользуется и страница регламента, доступная всегда,
независимо от того, включён ли модуль.
## [0.0.29] — 2026-08-04
Кнопка «демонстрация экрана» в мини-окне конференции.
### Добавлено
- В мини-окне (Document PiP) рядом с кнопкой микрофона появилась кнопка
демонстрации экрана: начать и остановить показ можно, не разворачивая
основное окно. Кнопка и кнопка основного тулбара отражают одно
состояние — обе читают его из комнаты, а не из разметки.
- Своя демонстрация показывается в мини-окне по обычным правилам сцены:
забирает крупную плитку при старте, держится, пока говорят другие, и
уступает говорящему либо закреплённому участнику после остановки.
### Примечания
- Кнопка есть только там, где мини-окно — настоящее окно со своей
разметкой, то есть в Chrome и Edge. В Safari мини-окно выводится
средствами системы (нативный «картинка в картинке»), собственных
кнопок в нём быть не может; в Firefox мини-окна нет вовсе. Это
ограничение браузеров, а не недоработка.
- При выборе «весь экран» мини-окно попадает в собственную
демонстрацию — как и у всех остальных участников. Чтобы этого
избежать, показывайте конкретное окно, а не экран целиком.
## [0.0.28] — 2026-08-04
Очередь поднятых рук видна всем участникам + отключаемый модуль.
### Добавлено
- Очередь поднятых рук больше не спрятана от обычных участников — её
видит любой, кто в конференции, не только организатор. Опустить чужую
руку по-прежнему может только организатор: сервер это уже проверял
(`api/chat.py`, задача B1), правки — только на фронте, кнопка
«Опустить» показывается у записи, если это своя рука либо пользователь
сам организатор.
- «Поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый модуль
в админке (`instance_settings.hand_queue`, дефолт `enabled=true`
поведение существующих инсталляций не меняется). Настройка едет
участнику в `JoinOut` (как `chat_enabled`) до входа в комнату.
Выключенный модуль гасит кнопки на фронте и отклоняет
`raise_hand`/`lower_hand` на сервере кодом `hand_queue_disabled`
вторая линия защиты для клиента со старым `JoinOut` на руках.
Переключение применяется со следующего входа в комнату (та же
застылость на время жизни соединения, что и у `chat_enabled`).
## [0.0.27] — 2026-08-04
Разрыв связи больше не выбрасывает участника из конференции.
### Исправлено
- Телефон с погасшим экраном выпадал из конференции, а при возвращении
мог оказаться в лобби вместо комнаты. Причина установлена по логам
прода: Chrome срезает ресурсы фоновой вкладке, телефон перестаёт
отвечать по ICE, и LiveKit закрывает участника через 5 с после потери
соединения (замерено: 37 с после блокировки экрана, 23 с после
сворачивания браузера — то есть достаточно просто убрать вкладку в
фон, гасить экран не обязательно). Восстановить сессию после этого
нельзя — участника на сервере уже нет; нужен полный повторный вход,
и `livekit-client` пытается сделать его сам, но его бюджет повторов
(10 попыток за ~44 с) в фоновой вкладке успевает сгореть. Тогда
приходило событие `Disconnected`, и страница комнаты уводила
пользователя в лобби.
Теперь непреднамеренный разрыв не уводит со страницы: сбрасывается
состояние входа, и работает уже имевшийся путь авто-перезахода —
резолв конференции, свежий токен, вход заново. Намеренный выход
отличается по нажатию кнопки «Выйти», а не по коду причины: причину
`CLIENT_INITIATED` присылает и кнопка, и сам `livekit-client`, который
при заморозке вкладки (событие `freeze`) вызывает `disconnect()`
самостоятельно. Разрывы, после которых возвращаться нельзя (участника
выгнал организатор, конференция закрыта, вход той же личностью с
другого устройства), уводят в лобби как раньше. От цикла «вошёл —
сразу выбросило» защищает лимит в 5 перезаходов подряд; соединение,
прожившее дольше 30 с, счётчик обнуляет.
## [0.0.26] — 2026-08-04 ## [0.0.26] — 2026-08-04
Мобильная комната: свайп шторки настроек и полноэкранный режим. Мобильная комната: свайп шторки настроек и полноэкранный режим.

View File

@@ -1 +1 @@
0.0.26 0.0.30

View File

@@ -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')

View File

@@ -62,6 +62,7 @@ from services.email import EmailSendError, create_email_backend
from services.instance_settings import ( from services.instance_settings import (
InstanceSettingsService, InstanceSettingsService,
InvalidAiLevelError, InvalidAiLevelError,
InvalidConsentPolicyError,
InvalidContactEmailError, InvalidContactEmailError,
InvalidEmailDomainError, InvalidEmailDomainError,
InvalidTimezoneError, InvalidTimezoneError,
@@ -404,6 +405,7 @@ async def update_settings(
InvalidTimezoneError, InvalidTimezoneError,
InvalidEmailDomainError, InvalidEmailDomainError,
InvalidContactEmailError, InvalidContactEmailError,
InvalidConsentPolicyError,
) as exc: ) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from 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) queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
@@ -456,6 +458,7 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI.""" """Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
return SettingsOut( return SettingsOut(
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
transcription_enabled=cfg.transcriber.enabled, transcription_enabled=cfg.transcriber.enabled,
ai_level=cfg.ai_level, ai_level=cfg.ai_level,
ai_levels=detect_ai_levels(cfg), ai_levels=detect_ai_levels(cfg),
@@ -469,6 +472,9 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
contact_email=cfg.contact_email, contact_email=cfg.contact_email,
publish_quality_cap=cfg.media_limits.publish_quality_cap, publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles, 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,
) )

View File

@@ -21,6 +21,7 @@ from schemas.auth import (
) )
from services.auth import ( from services.auth import (
AuthService, AuthService,
ConsentRequiredError,
EmailAlreadyRegisteredError, EmailAlreadyRegisteredError,
EmailNotVerifiedError, EmailNotVerifiedError,
InvalidCredentialsError, InvalidCredentialsError,
@@ -63,7 +64,12 @@ async def registration_options(
teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items] 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 [] email_domains = cfg.registration_email_domains if cfg.registration_email_domain_enabled else []
return RegistrationOptionsOut( 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, name_user=data.name_user,
password=data.password, password=data.password,
team_id=data.team_id, team_id=data.team_id,
consent_accepted=data.consent_accepted,
) )
except EmailAlreadyRegisteredError as exc: except EmailAlreadyRegisteredError as exc:
raise HTTPException( raise HTTPException(
@@ -91,6 +98,10 @@ async def register(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_email_domain" status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_email_domain"
) from exc ) 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) @router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -71,6 +71,7 @@ async def chat_websocket(
except ChatAuthError as exc: except ChatAuthError as exc:
await _close_quietly(websocket, exc.close_code) await _close_quietly(websocket, exc.close_code)
return return
hand_queue_enabled = await service.hand_queue_enabled()
pubsub = redis_client.pubsub() pubsub = redis_client.pubsub()
channel = chat_channel(conference.id) channel = chat_channel(conference.id)
@@ -94,7 +95,11 @@ async def chat_websocket(
async with asyncio.TaskGroup() as tg: async with asyncio.TaskGroup() as tg:
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, channel, seen_ids)) tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, channel, seen_ids))
tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity)) tg.create_task(
_pump_websocket_to_service(
websocket, service, conference, identity, hand_queue_enabled
)
)
except* WebSocketDisconnect: except* WebSocketDisconnect:
# Штатное закрытие соединения клиентом — не ошибка. # Штатное закрытие соединения клиентом — не ошибка.
pass pass
@@ -163,9 +168,20 @@ async def _pump_pubsub_to_websocket(
async def _pump_websocket_to_service( async def _pump_websocket_to_service(
websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity websocket: WebSocket,
service: ChatService,
conference: Conference,
identity: ChatIdentity,
hand_queue_enabled: bool,
) -> None: ) -> None:
"""Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать.""" """Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать.
`hand_queue_enabled` — снятый один раз при подключении тоггл модуля
«поднятие руки» (см. `ChatService.hand_queue_enabled`): при `False`
`raise_hand`/`lower_hand` отклоняются кодом `hand_queue_disabled` — вторая
линия защиты сверх того, что фронт при выключенном модуле вообще не
рисует кнопки (см. `RoomToolbar`/`HandQueueMenu`).
"""
is_organizer = conference.owner_id is not None and conference.owner_id == identity.user_id is_organizer = conference.owner_id is not None and conference.owner_id == identity.user_id
while True: while True:
raw = await websocket.receive_text() raw = await websocket.receive_text()
@@ -178,11 +194,21 @@ async def _pump_websocket_to_service(
if isinstance(envelope, ChatMessageIn): if isinstance(envelope, ChatMessageIn):
await service.persist_and_publish(conference, identity=identity, text=envelope.text) await service.persist_and_publish(conference, identity=identity, text=envelope.text)
elif isinstance(envelope, RaiseHandIn): elif isinstance(envelope, RaiseHandIn):
if not hand_queue_enabled:
await websocket.send_json(
ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json")
)
continue
await hand_queue.raise_hand( await hand_queue.raise_hand(
conference.id, identity=_identity_key(identity), name=identity.author_name conference.id, identity=_identity_key(identity), name=identity.author_name
) )
await hand_queue.publish_snapshot(conference.id) await hand_queue.publish_snapshot(conference.id)
else: else:
if not hand_queue_enabled:
await websocket.send_json(
ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json")
)
continue
target = envelope.identity or _identity_key(identity) target = envelope.identity or _identity_key(identity)
if target != _identity_key(identity) and not is_organizer: if target != _identity_key(identity) and not is_organizer:
await websocket.send_json( await websocket.send_json(

View File

@@ -33,12 +33,19 @@ class ChatConfig(BaseModel):
enabled: bool = True enabled: bool = True
class HandQueueConfig(BaseModel):
"""Конфигурация переключателя модуля «поднятие руки» (кнопка + очередь целиком)."""
enabled: bool = True
class PluginsConfig(BaseModel): class PluginsConfig(BaseModel):
"""Корневая модель конфигурации для `config/plugins.yaml`.""" """Корневая модель конфигурации для `config/plugins.yaml`."""
transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig) transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig)
summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig) summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig)
chat: ChatConfig = Field(default_factory=ChatConfig) chat: ChatConfig = Field(default_factory=ChatConfig)
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
def load_plugins_config(path: str | Path) -> PluginsConfig: def load_plugins_config(path: str | Path) -> PluginsConfig:
@@ -92,6 +99,7 @@ class InstanceConfig(BaseModel):
transcriber: TranscriberConfig transcriber: TranscriberConfig
summarizer: SummarizerConfig summarizer: SummarizerConfig
chat: ChatConfig chat: ChatConfig
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
ai_level: AiLevel = "min" ai_level: AiLevel = "min"
summary_recipients: SummaryRecipientsMode = "all" summary_recipients: SummaryRecipientsMode = "all"
display_timezone: str = "Europe/Moscow" display_timezone: str = "Europe/Moscow"
@@ -115,3 +123,13 @@ class InstanceConfig(BaseModel):
# не только в админке — настройка должна быть на руках у клиента до # не только в админке — настройка должна быть на руках у клиента до
# публикации трека. # публикации трека.
media_limits: MediaLimitsConfig = Field(default_factory=MediaLimitsConfig) 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

View File

@@ -3,7 +3,17 @@
import uuid import uuid
from datetime import datetime 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.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -35,6 +45,13 @@ class User(Base):
# Путь к загруженному аватару (относительно `MEDIA_ROOT`): # Путь к загруженному аватару (относительно `MEDIA_ROOT`):
# `avatars/{user_id}.{ext}`; `NULL` — заглушка с инициалами на фронте. # `avatars/{user_id}.{ext}`; `NULL` — заглушка с инициалами на фронте.
avatar_path: Mapped[str | None] = mapped_column(String(512), nullable=True) 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )

View File

@@ -1,6 +1,7 @@
"""Репозиторий доступа к таблице `users`.""" """Репозиторий доступа к таблице `users`."""
import uuid import uuid
from datetime import datetime
from sqlalchemy import or_, select from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,9 +31,24 @@ class UserRepository:
name_user: str, name_user: str,
password_hash: str, password_hash: str,
team_id: uuid.UUID | None = None, team_id: uuid.UUID | None = None,
consent_version: int | None = None,
consent_given_at: datetime | None = None,
) -> User: ) -> User:
"""Создать нового пользователя (role='user', email_verified=False по умолчанию).""" """Создать нового пользователя (role='user', email_verified=False по умолчанию).
user = User(email=email, name_user=name_user, password_hash=password_hash, team_id=team_id)
`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) self._session.add(user)
await self._session.flush() await self._session.flush()
return user return user

View File

@@ -147,6 +147,7 @@ class SettingsOut(BaseModel):
""" """
chat_enabled: bool chat_enabled: bool
hand_queue_enabled: bool
transcription_enabled: bool transcription_enabled: bool
ai_level: AiLevel ai_level: AiLevel
ai_levels: list[AiLevelStatus] ai_levels: list[AiLevelStatus]
@@ -162,6 +163,11 @@ class SettingsOut(BaseModel):
# качества публикации и максимум плиток сцены, см. `core/plugins/config.py`. # качества публикации и максимум плиток сцены, см. `core/plugins/config.py`.
publish_quality_cap: PublishQualityCap publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles stage_max_tiles: StageMaxTiles
# Согласие на обработку персональных данных при регистрации — см.
# `core/plugins/config.py::InstanceConfig`.
consent_required: bool
consent_policy_text: str
consent_policy_version: int
class TestEmailIn(BaseModel): class TestEmailIn(BaseModel):

View File

@@ -11,12 +11,18 @@ class RegisterIn(BaseModel):
`team_id` допустим только при включённой настройке инстанса `team_id` допустим только при включённой настройке инстанса
`registration_team_choice` (см. `GET /auth/registration-options`) и `registration_team_choice` (см. `GET /auth/registration-options`) и
существующей команде — иначе `POST /auth/register` вернёт 400. существующей команде — иначе `POST /auth/register` вернёт 400.
`consent_accepted` обязан быть `True`, если в настройках инстанса
включено `consent_required` (согласие на обработку персональных
данных) — иначе `POST /auth/register` вернёт 400. Игнорируется, если
настройка выключена (второй эшелон проверки — фронт тоже блокирует
кнопку, но сервер не полагается на это).
""" """
email: EmailStr email: EmailStr
name_user: str = Field(min_length=1, max_length=255) name_user: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=8) password: str = Field(min_length=8)
team_id: uuid.UUID | None = None team_id: uuid.UUID | None = None
consent_accepted: bool = False
class VerifyEmailIn(BaseModel): class VerifyEmailIn(BaseModel):
@@ -111,3 +117,10 @@ class RegistrationOptionsOut(BaseModel):
team_choice_enabled: bool team_choice_enabled: bool
teams: list[RegistrationTeamOptionOut] teams: list[RegistrationTeamOptionOut]
email_domains: list[str] = Field(default_factory=list) 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

View File

@@ -130,6 +130,9 @@ class JoinOut(BaseModel):
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает, # Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
# показывать ли UI чата, не дожидаясь ошибки WS-подключения. # показывать ли UI чата, не дожидаясь ошибки WS-подключения.
chat_enabled: bool chat_enabled: bool
# Тоггл инстанса `hand_queue.enabled` на момент входа — гасит модуль
# «поднятие руки» целиком (кнопка + очередь), см. `HandQueueConfig`.
hand_queue_enabled: bool
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются # Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
# прямо в join-ответе, а не только в админке: участнику нужно иметь их # прямо в join-ответе, а не только в админке: участнику нужно иметь их
# на руках ДО публикации своего трека (см. `services/conference_access.py`). # на руках ДО публикации своего трека (см. `services/conference_access.py`).

View File

@@ -58,6 +58,15 @@ class InvalidEmailDomainError(Exception):
""" """
class ConsentRequiredError(Exception):
"""Согласие на обработку персональных данных не отмечено.
Поднимается только при включённой настройке инстанса `consent_required`
(см. `InstanceSettingsService`) — второй эшелон проверки, фронт уже не
даёт отправить форму без галочки, но сервер не полагается на это.
"""
class InvalidVerificationTokenError(Exception): class InvalidVerificationTokenError(Exception):
"""Токен подтверждения email не найден, просрочен или уже использован.""" """Токен подтверждения email не найден, просрочен или уже использован."""
@@ -99,6 +108,7 @@ class AuthService:
name_user: str, name_user: str,
password: str, password: str,
team_id: uuid.UUID | None = None, team_id: uuid.UUID | None = None,
consent_accepted: bool = False,
) -> User: ) -> User:
"""Зарегистрировать пользователя и отправить письмо для подтверждения email. """Зарегистрировать пользователя и отправить письмо для подтверждения email.
@@ -109,7 +119,12 @@ class AuthService:
email (`registration_email_domain_enabled`), домен `email` (часть email (`registration_email_domain_enabled`), домен `email` (часть
после `@`, без учёта регистра) должен совпадать с одним из после `@`, без учёта регистра) должен совпадать с одним из
эталонных доменов (`registration_email_domains`) — иначе эталонных доменов (`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) existing = await self._users.get_by_email(email)
if existing is not None: if existing is not None:
@@ -129,11 +144,22 @@ class AuthService:
if team is None: if team is None:
raise InvalidTeamSelectionError(team_id) 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( user = await self._users.create(
email=email, email=email,
name_user=name_user, name_user=name_user,
password_hash=await hash_password(password), password_hash=await hash_password(password),
team_id=team_id, 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 reply_to = cfg.contact_email if cfg.contact_email_enabled else None
await self._issue_verification_email(user, reply_to=reply_to) await self._issue_verification_email(user, reply_to=reply_to)

View File

@@ -148,6 +148,15 @@ class ChatService:
raise WrongRoomError raise WrongRoomError
return conference return conference
async def hand_queue_enabled(self) -> bool:
"""Тоггл инстанса `hand_queue.enabled` — снимается один раз при подключении WS
(см. `api/chat.py::chat_websocket`), а не на каждое сообщение: та же
осознанная «застылость» на время жизни соединения, что и у
`is_organizer` в `_pump_websocket_to_service` — переключение модуля
администратором применяется со следующего подключения."""
cfg = await InstanceSettingsService(self._session).get()
return cfg.hand_queue.enabled
async def history(self, conference: Conference) -> list[ChatMessageOut]: async def history(self, conference: Conference) -> list[ChatMessageOut]:
"""Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет).""" """Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет)."""
session_record = await self._sessions.get_open_by_conference(conference.id) session_record = await self._sessions.get_open_by_conference(conference.id)

View File

@@ -53,6 +53,7 @@ def build_join(
identity: str, identity: str,
name: str, name: str,
chat_enabled: bool, chat_enabled: bool,
hand_queue_enabled: bool,
publish_quality_cap: PublishQualityCap, publish_quality_cap: PublishQualityCap,
stage_max_tiles: StageMaxTiles, stage_max_tiles: StageMaxTiles,
avatar_url: str | None = None, avatar_url: str | None = None,
@@ -61,9 +62,10 @@ def build_join(
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции. """Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4). Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей `chat_enabled`/`hand_queue_enabled`/`publish_quality_cap`/`stage_max_tiles`
стороной значения `instance_settings`: читаются здесь параметрами, а не — снятые вызывающей стороной значения `instance_settings`: читаются здесь
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join. параметрами, а не заново из БД, чтобы не плодить отдельный запрос настроек
на каждый join.
`avatar_url`/`is_organizer` `avatar_url`/`is_organizer`
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}` прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
— поля добавляются, только если заданы (гость без аватара и не-организатор — поля добавляются, только если заданы (гость без аватара и не-организатор
@@ -91,6 +93,7 @@ def build_join(
room_name=conference.slug, room_name=conference.slug,
conference_id=conference.id, conference_id=conference.id,
chat_enabled=chat_enabled, chat_enabled=chat_enabled,
hand_queue_enabled=hand_queue_enabled,
publish_quality_cap=publish_quality_cap, publish_quality_cap=publish_quality_cap,
stage_max_tiles=stage_max_tiles, stage_max_tiles=stage_max_tiles,
) )

View File

@@ -155,6 +155,7 @@ class ConferenceService:
identity=str(owner_id), identity=str(owner_id),
name=owner_name, name=owner_name,
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap, publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles, stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path), avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
@@ -251,6 +252,7 @@ class ConferenceService:
identity=str(user.id), identity=str(user.id),
name=user.name_user, name=user.name_user,
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap, publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles, stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path), avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
@@ -275,6 +277,7 @@ class ConferenceService:
identity=f"guest:{guest.id}", identity=f"guest:{guest.id}",
name=data.display_name, name=data.display_name,
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap, publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles, stage_max_tiles=cfg.media_limits.stage_max_tiles,
) )

View File

@@ -1,7 +1,7 @@
"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап. """Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`, Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`, `hand_queue`, `ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`, `contact_email`, `registration_team_choice`, `registration_email_domain`, `contact_email`,
`media_limits`) — `media_limits`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`) новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
@@ -28,6 +28,7 @@ from core.config import Settings
from core.plugins.config import ( from core.plugins.config import (
AiLevel, AiLevel,
ChatConfig, ChatConfig,
HandQueueConfig,
InstanceConfig, InstanceConfig,
MediaLimitsConfig, MediaLimitsConfig,
PluginsConfig, PluginsConfig,
@@ -45,6 +46,7 @@ from services.ai_tiers import TIERS
_KEY_TRANSCRIBER = "transcriber" _KEY_TRANSCRIBER = "transcriber"
_KEY_SUMMARIZER = "summarizer" _KEY_SUMMARIZER = "summarizer"
_KEY_CHAT = "chat" _KEY_CHAT = "chat"
_KEY_HAND_QUEUE = "hand_queue"
_KEY_AI_LEVEL = "ai_level" _KEY_AI_LEVEL = "ai_level"
_KEY_SUMMARY_RECIPIENTS = "summary_recipients" _KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone" _KEY_DISPLAY_TIMEZONE = "display_timezone"
@@ -52,6 +54,7 @@ _KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain" _KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
_KEY_CONTACT_EMAIL = "contact_email" _KEY_CONTACT_EMAIL = "contact_email"
_KEY_MEDIA_LIMITS = "media_limits" _KEY_MEDIA_LIMITS = "media_limits"
_KEY_CONSENT_POLICY = "consent_policy"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = ( BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT, _KEY_CHAT,
@@ -77,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_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_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). # без пробелов — валидация после нормализации (strip, «@», lower).
@@ -100,6 +137,7 @@ class SettingsUpdateIn(BaseModel):
""" """
chat_enabled: bool | None = None chat_enabled: bool | None = None
hand_queue_enabled: bool | None = None
transcription_enabled: bool | None = None transcription_enabled: bool | None = None
ai_level: AiLevel | None = None ai_level: AiLevel | None = None
summary_recipients: SummaryRecipientsMode | None = None summary_recipients: SummaryRecipientsMode | None = None
@@ -111,6 +149,8 @@ class SettingsUpdateIn(BaseModel):
contact_email: str | None = None contact_email: str | None = None
publish_quality_cap: PublishQualityCap | None = None publish_quality_cap: PublishQualityCap | None = None
stage_max_tiles: StageMaxTiles | None = None stage_max_tiles: StageMaxTiles | None = None
consent_required: bool | None = None
consent_policy_text: str | None = None
class BootstrapOverrides(BaseModel): class BootstrapOverrides(BaseModel):
@@ -154,6 +194,7 @@ def build_bootstrap_defaults(
_KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"), _KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"),
_KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"), _KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"),
_KEY_CHAT: plugins.chat.model_dump(mode="json"), _KEY_CHAT: plugins.chat.model_dump(mode="json"),
_KEY_HAND_QUEUE: plugins.hand_queue.model_dump(mode="json"),
_KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE), _KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE),
_KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE), _KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE),
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE), _KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
@@ -161,6 +202,7 @@ def build_bootstrap_defaults(
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE), _KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE), _KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
_KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE), _KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE),
_KEY_CONSENT_POLICY: dict(_DEFAULT_CONSENT_POLICY_VALUE),
} }
if overrides is None: if overrides is None:
return defaults return defaults
@@ -197,6 +239,11 @@ class InvalidEmailDomainError(ValueError):
""" """
class InvalidConsentPolicyError(ValueError):
"""Попытка включить обязательное согласие при пустом тексте регламента
(`consent_required=True` без непустого `consent_policy_text`)."""
class InvalidContactEmailError(ValueError): class InvalidContactEmailError(ValueError):
"""Некорректная настройка контактного адреса инстанса. """Некорректная настройка контактного адреса инстанса.
@@ -286,6 +333,10 @@ class InstanceSettingsService:
cfg.chat = ChatConfig(enabled=patch.chat_enabled) cfg.chat = ChatConfig(enabled=patch.chat_enabled)
await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json")) await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json"))
if patch.hand_queue_enabled is not None:
cfg.hand_queue = HandQueueConfig(enabled=patch.hand_queue_enabled)
await self._set(_KEY_HAND_QUEUE, cfg.hand_queue.model_dump(mode="json"))
if patch.registration_team_choice is not None: if patch.registration_team_choice is not None:
cfg.registration_team_choice = patch.registration_team_choice cfg.registration_team_choice = patch.registration_team_choice
await self._set( await self._set(
@@ -363,6 +414,34 @@ class InstanceSettingsService:
cfg.media_limits = MediaLimitsConfig(publish_quality_cap=cap, stage_max_tiles=max_tiles) 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")) 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() await self._session.commit()
return cfg return cfg
@@ -399,6 +478,7 @@ async def load_effective_config(session: AsyncSession) -> InstanceConfig:
transcriber=plugins.transcriber, transcriber=plugins.transcriber,
summarizer=plugins.summarizer, summarizer=plugins.summarizer,
chat=plugins.chat, chat=plugins.chat,
hand_queue=plugins.hand_queue,
) )
else: else:
cfg = _build_config(rows) cfg = _build_config(rows)
@@ -492,6 +572,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})), transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})),
summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})), summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})),
chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})), chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})),
hand_queue=HandQueueConfig.model_validate(rows.get(_KEY_HAND_QUEUE, {})),
ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"), ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"),
summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get( summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get(
"mode", "all" "mode", "all"
@@ -515,4 +596,13 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
media_limits=MediaLimitsConfig.model_validate( media_limits=MediaLimitsConfig.model_validate(
rows.get(_KEY_MEDIA_LIMITS, _DEFAULT_MEDIA_LIMITS_VALUE) 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
),
) )

View File

@@ -646,12 +646,17 @@ async def test_put_settings_partial_update(
response = await client.put( response = await client.put(
"/api/v1/admin/settings", "/api/v1/admin/settings",
json={"chat_enabled": False, "display_timezone": "Asia/Yekaterinburg"}, json={
"chat_enabled": False,
"hand_queue_enabled": False,
"display_timezone": "Asia/Yekaterinburg",
},
headers=_auth_headers(admin), headers=_auth_headers(admin),
) )
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
body = response.json() body = response.json()
assert body["chat_enabled"] is False assert body["chat_enabled"] is False
assert body["hand_queue_enabled"] is False
assert body["display_timezone"] == "Asia/Yekaterinburg" assert body["display_timezone"] == "Asia/Yekaterinburg"
assert body["ai_level"] == "min" assert body["ai_level"] == "min"
assert body["transcription_queue_served"] is False assert body["transcription_queue_served"] is False

View File

@@ -40,7 +40,11 @@ async def _reset_registration_gating(db_session: AsyncSession) -> None:
`conftest.py`). `conftest.py`).
""" """
await InstanceSettingsService(db_session).update( 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() 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 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( async def test_login_rehashes_legacy_password(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None: ) -> None:

View File

@@ -8,13 +8,15 @@ import uuid
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from core.security import hash_password from core.security import create_access_token, hash_password
from models.conference import Conference from models.conference import Conference
from models.guest import GuestAccess from models.guest import GuestAccess
from models.user import User from models.user import User
from services.conference_ids import generate_number, generate_slug from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
from services.livekit_tokens import create_room_access_token from services.livekit_tokens import create_room_access_token
from tests.conftest import ASGIWebSocketSession from tests.conftest import ASGIWebSocketSession
@@ -270,3 +272,66 @@ async def test_organizer_joining_late_sees_already_raised_hands(
initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner)) initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
assert _identities(initial_queue) == [str(alice.id)] assert _identities(initial_queue) == [str(alice.id)]
# --- Отключаемый модуль (`instance_settings.hand_queue`) ---------------------
async def test_hand_queue_disabled_rejects_raise_and_lower(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Выключенный модуль — вторая линия защиты сверх фронта: `raise_hand`/
`lower_hand` отклоняются кодом `hand_queue_disabled`, очередь не меняется."""
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
await db_session.commit()
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
ws = ws_client(_chat_path(conference.id))
await _connect_auth_and_queue(ws, _user_token(conference, alice))
await ws.send_json({"type": "raise_hand"})
error = await ws.receive_json()
assert error == {"type": "error", "code": "hand_queue_disabled"}
await ws.send_json({"type": "lower_hand"})
error = await ws.receive_json()
assert error == {"type": "error", "code": "hand_queue_disabled"}
async def test_join_out_reflects_hand_queue_enabled_toggle(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session, name="Toggle Tester")
await db_session.commit()
headers = {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
response = await client.post(
"/api/v1/conferences", json={"title": "Standup"}, headers=headers
)
assert response.status_code == 201, response.text
assert response.json()["join"]["hand_queue_enabled"] is True
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
response = await client.post(
"/api/v1/conferences", json={"title": "Standup 2"}, headers=headers
)
assert response.status_code == 201, response.text
assert response.json()["join"]["hand_queue_enabled"] is False
async def test_guest_join_out_reflects_hand_queue_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Dave"}
)
assert response.status_code == 200, response.text
assert response.json()["hand_queue_enabled"] is False

View File

@@ -48,6 +48,7 @@ from services.instance_settings import (
BootstrapOverrides, BootstrapOverrides,
InstanceSettingsService, InstanceSettingsService,
InvalidAiLevelError, InvalidAiLevelError,
InvalidConsentPolicyError,
InvalidContactEmailError, InvalidContactEmailError,
InvalidEmailDomainError, InvalidEmailDomainError,
InvalidTimezoneError, InvalidTimezoneError,
@@ -65,6 +66,7 @@ _MANAGED_KEYS = (
"transcriber", "transcriber",
"summarizer", "summarizer",
"chat", "chat",
"hand_queue",
"ai_level", "ai_level",
"summary_recipients", "summary_recipients",
"display_timezone", "display_timezone",
@@ -72,6 +74,7 @@ _MANAGED_KEYS = (
"registration_email_domain", "registration_email_domain",
"contact_email", "contact_email",
"media_limits", "media_limits",
"consent_policy",
) )
@@ -121,6 +124,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"transcriber", "transcriber",
"summarizer", "summarizer",
"chat", "chat",
"hand_queue",
"ai_level", "ai_level",
"summary_recipients", "summary_recipients",
"display_timezone", "display_timezone",
@@ -128,10 +132,14 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"registration_email_domain", "registration_email_domain",
"contact_email", "contact_email",
"media_limits", "media_limits",
"consent_policy",
} }
cfg = await service.get() cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu" assert cfg.transcriber.provider == "faster_whisper_cpu"
assert cfg.ai_level == "min" assert cfg.ai_level == "min"
# Дефолт обязан сохранять поведение существующих инсталляций — модуль
# «поднятие руки» был доступен всегда, тоггл включён по умолчанию.
assert cfg.hand_queue.enabled is True
assert cfg.summary_recipients == "all" assert cfg.summary_recipients == "all"
assert cfg.display_timezone == "Europe/Moscow" assert cfg.display_timezone == "Europe/Moscow"
assert cfg.registration_team_choice is False assert cfg.registration_team_choice is False
@@ -144,6 +152,13 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
# потолку `StageGrid` (5×5). # потолку `StageGrid` (5×5).
assert cfg.media_limits.publish_quality_cap == "off" assert cfg.media_limits.publish_quality_cap == "off"
assert cfg.media_limits.stage_max_tiles == 25 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( async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
@@ -162,6 +177,22 @@ async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
assert cfg.display_timezone == "Asia/Yekaterinburg" assert cfg.display_timezone == "Asia/Yekaterinburg"
async def test_update_hand_queue_enabled(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=False))
assert cfg.hand_queue.enabled is False
cfg = await service.get()
assert cfg.hand_queue.enabled is False
cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=True))
assert cfg.hand_queue.enabled is True
@pytest.mark.parametrize( @pytest.mark.parametrize(
("preset", "chat_enabled", "ai_enabled", "ai_level"), ("preset", "chat_enabled", "ai_enabled", "ai_level"),
[ [
@@ -330,6 +361,61 @@ async def test_registration_team_choice_toggle(
assert reloaded.registration_team_choice is True 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( async def test_registration_email_domain_enable_without_domain_rejected(
db_session: AsyncSession, clean_instance_settings: None db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None:

View File

@@ -33,3 +33,6 @@ summarizer:
chat: chat:
enabled: true enabled: true
hand_queue:
enabled: true

View File

@@ -89,7 +89,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.26} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.30}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан # Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение, # на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул # проверьте бюджет соединений с БД: каждый воркер держит свой пул

View File

@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { LoginPage } from '@/pages/LoginPage' import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage' import { RegisterPage } from '@/pages/RegisterPage'
import { VerifyEmailPage } from '@/pages/VerifyEmailPage' import { VerifyEmailPage } from '@/pages/VerifyEmailPage'
import { ConsentPolicyPage } from '@/pages/ConsentPolicyPage'
import { LobbyPage } from '@/pages/LobbyPage' import { LobbyPage } from '@/pages/LobbyPage'
import { JoinPage } from '@/pages/JoinPage' import { JoinPage } from '@/pages/JoinPage'
import { RoomPage } from '@/pages/RoomPage' import { RoomPage } from '@/pages/RoomPage'
@@ -19,6 +20,10 @@ function App() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} /> <Route path="/verify-email" element={<VerifyEmailPage />} />
{/* Публичная страница регламента обработки ПДн — читается до регистрации,
когда пользователя ещё нет; ссылка на неё — рядом с галочкой согласия
на RegisterPage. */}
<Route path="/legal/personal-data-consent" element={<ConsentPolicyPage />} />
<Route <Route
path="/lobby" path="/lobby"
element={ element={

View File

@@ -19,6 +19,8 @@ export interface AiLevelStatus {
/** Эффективные настройки инстанса. */ /** Эффективные настройки инстанса. */
export interface SettingsOut { export interface SettingsOut {
chat_enabled: boolean chat_enabled: boolean
/** Включён ли модуль «поднятие руки» — кнопка «Рука» и очередь целиком. */
hand_queue_enabled: boolean
/** Единый переключатель модуля AI (транскрибация + суммаризация). */ /** Единый переключатель модуля AI (транскрибация + суммаризация). */
transcription_enabled: boolean transcription_enabled: boolean
/** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */ /** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */
@@ -43,11 +45,18 @@ export interface SettingsOut {
publish_quality_cap: PublishQualityCap publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`). */ /** Максимум одновременно видимых плиток сцены (`StageGrid`). */
stage_max_tiles: number stage_max_tiles: number
/** Обязательна ли галочка согласия на обработку персональных данных при регистрации. */
consent_required: boolean
/** Текст регламента (редактируемый шаблон, дефолт — типовой образец без юридической силы). */
consent_policy_text: string
/** Номер редакции текста — растёт при каждом изменении `consent_policy_text`. */
consent_policy_version: number
} }
/** Тело частичного обновления настроек инстанса — все поля опциональны. */ /** Тело частичного обновления настроек инстанса — все поля опциональны. */
export interface SettingsUpdateIn { export interface SettingsUpdateIn {
chat_enabled?: boolean chat_enabled?: boolean
hand_queue_enabled?: boolean
transcription_enabled?: boolean transcription_enabled?: boolean
/** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */ /** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */
ai_level?: AiLevel ai_level?: AiLevel
@@ -62,6 +71,9 @@ export interface SettingsUpdateIn {
contact_email?: string | null contact_email?: string | null
publish_quality_cap?: PublishQualityCap publish_quality_cap?: PublishQualityCap
stage_max_tiles?: number stage_max_tiles?: number
/** Включение с пустым текстом регламента — backend отвечает 400. */
consent_required?: boolean
consent_policy_text?: string
} }
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */ /** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */

View File

@@ -9,6 +9,8 @@ export interface RegisterPayload {
password: string password: string
/** Выбранная команда — только если выбор команды включён в настройках инстанса. */ /** Выбранная команда — только если выбор команды включён в настройках инстанса. */
team_id?: string | null team_id?: string | null
/** Согласие на обработку персональных данных — обязано быть `true`, если `consent_required`. */
consent_accepted?: boolean
} }
/** Команда, доступная для выбора на экране регистрации. */ /** Команда, доступная для выбора на экране регистрации. */
@@ -23,6 +25,12 @@ export interface RegistrationOptions {
teams: RegistrationTeamOption[] teams: RegistrationTeamOption[]
/** Эталонные домены почты при включённой верификации (email подходит под любой), иначе пуст. */ /** Эталонные домены почты при включённой верификации (email подходит под любой), иначе пуст. */
email_domains: string[] email_domains: string[]
/** Обязательна ли галочка согласия на обработку персональных данных на форме регистрации. */
consent_required: boolean
/** Текст регламента — отдаётся всегда, независимо от `consent_required` (нужен и странице регламента). */
consent_text: string
/** Номер редакции текста, с которой согласится пользователь при регистрации. */
consent_version: number
} }
export interface CurrentUser { export interface CurrentUser {

View File

@@ -61,6 +61,8 @@ export interface ConferenceJoinData {
conference_id: string conference_id: string
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */ /** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
chat_enabled: boolean chat_enabled: boolean
/** Включён ли модуль «поднятие руки» — при `false` кнопка «Рука» и очередь не рендерятся. */
hand_queue_enabled: boolean
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */ /** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */ /** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */

View File

@@ -68,6 +68,7 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const { user } = useAuth() const { user } = useAuth()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled) const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [handQueueEnabled, setHandQueueEnabled] = useState(data.hand_queue_enabled)
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled) const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level) const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level)
const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients) const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients)
@@ -80,6 +81,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '') const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
const [publishQualityCap, setPublishQualityCap] = useState<PublishQualityCap>(data.publish_quality_cap) const [publishQualityCap, setPublishQualityCap] = useState<PublishQualityCap>(data.publish_quality_cap)
const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles) 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 [testEmailTo, setTestEmailTo] = useState('')
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null) const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
@@ -91,7 +94,11 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 400) { if (err instanceof ApiError && err.status === 400) {
toast.show(errorDetail(err) ?? 'Недоступное значение — проверьте уровень AI, таймзону и домен почты', 'error') toast.show(
errorDetail(err) ??
'Недоступное значение — проверьте уровень AI, таймзону, домен почты и текст регламента',
'error',
)
} else { } else {
toast.show('Не удалось сохранить настройки', 'error') toast.show('Не удалось сохранить настройки', 'error')
} }
@@ -132,6 +139,7 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
// валился бы в 400, блокируя правку вообще любой другой настройки. // валился бы в 400, блокируя правку вообще любой другой настройки.
const payload: SettingsUpdateIn = {} const payload: SettingsUpdateIn = {}
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
if (handQueueEnabled !== data.hand_queue_enabled) payload.hand_queue_enabled = handQueueEnabled
if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
@@ -154,6 +162,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
} }
if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap
if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles 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) mutation.mutate(payload)
} }
@@ -175,6 +185,21 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</label> </label>
</div> </div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Поднятие руки</strong>
<span>Кнопка «Рука» и очередь поднятых рук в комнате целиком</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={handQueueEnabled}
onChange={(e) => setHandQueueEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="toggle-row"> <div className="toggle-row">
<div className="toggle-copy"> <div className="toggle-copy">
<strong>Транскрибация и суммаризация (AI)</strong> <strong>Транскрибация и суммаризация (AI)</strong>
@@ -461,6 +486,55 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</div> </div>
</section> </section>
<section className="settings-card">
<h2>Согласие на обработку персональных данных</h2>
<p className="desc">
Галочка на форме регистрации со ссылкой на регламент (страница{' '}
<code>/legal/personal-data-consent</code>). Факт согласия хранится в БД вместе с
номером редакции текста и датой.
</p>
<div className="settings-card-body">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Требовать согласие при регистрации</strong>
<span>Без отмеченной галочки кнопка регистрации неактивна, сервер тоже откажет</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={consentRequired}
onChange={(e) => setConsentRequired(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="field" style={{ marginBottom: 0, marginTop: 'var(--space-4)' }}>
<label htmlFor="settings-consent-text">
Текст регламента редакция {data.consent_policy_version}
</label>
<p className="field-hint" style={{ color: 'var(--color-danger)' }}>
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" /> Дефолтный
текст типовой образец, не проходил проверку юриста. Замените плейсхолдеры в
квадратных скобках (наименование оператора, адрес, контакты, цели и срок
обработки) под свою организацию, прежде чем включать требование согласия.
</p>
<textarea
id="settings-consent-text"
rows={12}
value={consentPolicyText}
onChange={(e) => setConsentPolicyText(e.target.value)}
style={{ width: '100%', fontFamily: 'inherit', resize: 'vertical' }}
/>
<p className="field-hint">
Сохранение изменённого текста автоматически увеличивает номер редакции это
значение фиксируется у каждого пользователя вместе с датой согласия.
</p>
</div>
</div>
</section>
<section className="settings-card"> <section className="settings-card">
<h2>Тестовое письмо</h2> <h2>Тестовое письмо</h2>
<p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p> <p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Hand, ListOrdered } from 'lucide-react' import { Hand, ListOrdered } from 'lucide-react'
import { useLocalParticipant } from '@livekit/components-react'
import { useIsOrganizer } from '@/hooks/useIsOrganizer' import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat' import type { HandQueueEntry } from '@/hooks/useChat'
@@ -9,8 +10,15 @@ interface HandQueueMenuProps {
} }
/** /**
* Кнопка «Очередь» в тулбаре с поповером над ней — видна только организатору * Кнопка «Очередь» в тулбаре с поповером над ней — видна ВСЕМ участникам
* (задача B1). Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»): * (сессия «28-hand-queue-for-all»: раньше очередь видел только организатор).
* Опустить чужую запись может по-прежнему только организатор — обычный
* участник видит кнопку «Опустить» только напротив СВОЕЙ записи (или не
* видит её вовсе, если сам руку не поднимал): сервер (`api/chat.py`) всё
* равно отклонит попытку опустить чужую руку кодом `forbidden`, но мёртвая
* кнопка, которая молча не работает, хуже отсутствующей.
*
* Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»):
* собственное состояние открытия, закрытие по клику вне/Escape, поповер * собственное состояние открытия, закрытие по клику вне/Escape, поповер
* `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат): * `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат):
* очередь рук — короткий список, а не история переписки, разворачивать её * очередь рук — короткий список, а не история переписки, разворачивать её
@@ -19,11 +27,12 @@ interface HandQueueMenuProps {
* Размер поповера подстраивается под число записей — `.hand-queue-list` * Размер поповера подстраивается под число записей — `.hand-queue-list`
* растёт вместе со списком и не даёт пустого места при 12 поднятых руках, * растёт вместе со списком и не даёт пустого места при 12 поднятых руках,
* но не бесконечно: после ~10 строк список упирается в `max-height` и дальше * но не бесконечно: после ~10 строк список упирается в `max-height` и дальше
* скроллится (см. room.css) — иначе организатор на энергичной встрече * скроллится (см. room.css) — иначе участник на энергичной встрече получил
* получил бы поповер выше экрана. * бы поповер выше экрана.
*/ */
export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) { export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
const isOrganizer = useIsOrganizer() const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null) const wrapRef = useRef<HTMLDivElement>(null)
@@ -47,8 +56,6 @@ export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
} }
}, [open]) }, [open])
if (!isOrganizer) return null
return ( return (
<div className="tb-menu-wrap" ref={wrapRef}> <div className="tb-menu-wrap" ref={wrapRef}>
<button <button
@@ -74,22 +81,28 @@ export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
<p className="chat-empty">Пока никто не поднял руку</p> <p className="chat-empty">Пока никто не поднял руку</p>
) : ( ) : (
<ol className="hand-queue-list"> <ol className="hand-queue-list">
{queue.map((entry, index) => ( {queue.map((entry, index) => {
<li className="hand-queue-item" key={entry.identity}> const isOwn = entry.identity === localParticipant.identity
<span className="hand-queue-position">{index + 1}</span> const canLower = isOrganizer || isOwn
<span className="hand-queue-name"> return (
<Hand className="lucide" aria-hidden="true" /> <li className="hand-queue-item" key={entry.identity}>
{entry.name} <span className="hand-queue-position">{index + 1}</span>
</span> <span className="hand-queue-name">
<button <Hand className="lucide" aria-hidden="true" />
type="button" {entry.name}
className="hand-queue-lower" </span>
onClick={() => onLower(entry.identity)} {canLower && (
> <button
Опустить type="button"
</button> className="hand-queue-lower"
</li> onClick={() => onLower(entry.identity)}
))} >
Опустить
</button>
)}
</li>
)
})}
</ol> </ol>
)} )}
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import { EyeOff, Mic, MicOff, Users } from 'lucide-react' import { EyeOff, Mic, MicOff, ScreenShare, ScreenShareOff, Users } from 'lucide-react'
import { Track, type Participant } from 'livekit-client' import { Track, type Participant } from 'livekit-client'
import { import {
CarouselLayout, CarouselLayout,
@@ -13,8 +13,10 @@ import {
} from '@livekit/components-react' } from '@livekit/components-react'
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile' import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
import { StageGrid } from '@/components/room/StageGrid' import { StageGrid } from '@/components/room/StageGrid'
import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus' import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus'
import { SCREEN_SHARE_CAPTURE_OPTIONS } from '@/lib/screenShareOptions'
import type { StageLayoutMode } from '@/lib/stageLayoutMode' import type { StageLayoutMode } from '@/lib/stageLayoutMode'
/** /**
@@ -115,7 +117,7 @@ function PipMicToggle() {
<button <button
type="button" type="button"
{...mic.buttonProps} {...mic.buttonProps}
className={`room-pip-mic-toggle${mic.enabled ? '' : ' is-off'}`} className={`room-pip-btn room-pip-mic-toggle${mic.enabled ? '' : ' is-off'}`}
aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'} aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'}
> >
{mic.enabled ? <Mic className="lucide" aria-hidden="true" /> : <MicOff className="lucide" aria-hidden="true" />} {mic.enabled ? <Mic className="lucide" aria-hidden="true" /> : <MicOff className="lucide" aria-hidden="true" />}
@@ -123,6 +125,59 @@ function PipMicToggle() {
) )
} }
/**
* Кнопка демонстрации экрана в мини-плеере — сестра `PipMicToggle` (0.0.29).
* Тот же `useTrackToggle` и те же `SCREEN_SHARE_CAPTURE_OPTIONS`, что у кнопки
* основного тулбара (`RoomToolbar`), поэтому обе кнопки — два вида одного
* состояния: включённая из мини-окна демонстрация показывает основную кнопку
* активной и наоборот, рассинхрону взяться неоткуда (состояние читается из
* `RoomContext`, а не из DOM).
*
* 🔬 ПОЧЕМУ ЭТО ВООБЩЕ РАБОТАЕТ ИЗ ДРУГОГО ОКНА. `getDisplayMedia()` требует
* транзиентной активации пользователя, а клик здесь происходит в PiP-окне,
* тогда как сам код (React-дерево целиком остаётся в основном документе,
* порталом уезжает только DOM) вызывает `navigator` ОСНОВНОГО окна. Замерено
* в Chrome 150 отдельной пробой: после клика в Document PiP
* `navigator.userActivation.isActive === true` в ОБОИХ окнах — активация
* доезжает до опенера, вызов проходит, системный пикер выбора экрана
* открывается отдельным окном поверх остальных (а не прячется за заглушкой
* «Конференция открыта в отдельном мини-окне» в основном окне). Проверять это
* пришлось живьём: спецификация такого поведения не обещает, и на других
* движках оно может отличаться — но Document PiP есть только в Chrome/Edge,
* так что других движков здесь и не бывает (в Safari мини-окно — нативный
* video-PiP без собственного DOM, кнопке там просто негде жить, в Firefox
* мини-окна нет вовсе).
*
* Ошибку показываем тостом основного окна: пользователь его сейчас не видит
* (там заглушка), но увидит, как только вернётся, — а единственный частый
* «сбой», отмена пикера, и так молча игнорируется, как в основном тулбаре.
*/
function PipScreenShareToggle() {
const toast = useToast()
const screenShare = useTrackToggle({
source: Track.Source.ScreenShare,
captureOptions: SCREEN_SHARE_CAPTURE_OPTIONS,
onDeviceError: (error) => {
if (error.name === 'NotAllowedError') return
toast.show('Не удалось начать демонстрацию экрана', 'error')
},
})
return (
<button
type="button"
{...screenShare.buttonProps}
className={`room-pip-btn room-pip-share-toggle${screenShare.enabled ? ' is-on' : ''}`}
aria-label={screenShare.enabled ? 'Остановить демонстрацию экрана' : 'Демонстрация экрана'}
>
{screenShare.enabled ? (
<ScreenShareOff className="lucide" aria-hidden="true" />
) : (
<ScreenShare className="lucide" aria-hidden="true" />
)}
</button>
)
}
/** /**
* Основная сцена конференции: превью остальных участников + крупная плитка * Основная сцена конференции: превью остальных участников + крупная плитка
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких * активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
@@ -441,12 +496,26 @@ export function RoomStage({
// демонстрации, антидребезг говорящего) — переиспользуем как есть. // демонстрации, антидребезг говорящего) — переиспользуем как есть.
// Булавки на плитке здесь нет намеренно: своего тулбара у мини-окна нет, // Булавки на плитке здесь нет намеренно: своего тулбара у мини-окна нет,
// закрепление делается в основном окне и приезжает сюда через // закрепление делается в основном окне и приезжает сюда через
// `initialPinnedKey`. // `initialPinnedKey`. А вот микрофон и демонстрация экрана в мини-окне есть:
// это не «вид», а действия, которые нужны прямо посреди разговора, и ради них
// разворачивать основное окно (то есть закрывать мини-окно) бессмысленно.
//
// СОБСТВЕННАЯ ДЕМОНСТРАЦИЯ в мини-окне ПОКАЗЫВАЕТСЯ — по тем же правилам
// `pickStageFocus`, что и в основном окне (last-wins на старте, возврат на
// говорящего/закреплённого после остановки). Решение оператора при приёмке
// 0.0.29: демонстратор должен видеть, что именно он демонстрирует, ровно то
// же, что видят остальные. Пробная версия прятала свой шэр из PiP (чтобы
// вместо него была видна аудитория и чтобы при выборе «весь экран» не
// получался зеркальный туннель «мини-окно внутри мини-окна») — отклонена.
// Туннель при выборе «весь экран» остаётся известным и принятым поведением.
if (variant === 'pip') { if (variant === 'pip') {
return ( return (
<section className="stage room-single-tile"> <section className="stage room-single-tile">
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />} {focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
<PipMicToggle /> <div className="room-pip-controls">
<PipMicToggle />
<PipScreenShareToggle />
</div>
</section> </section>
) )
} }

View File

@@ -13,34 +13,14 @@ import {
Video, Video,
VideoOff, VideoOff,
} from 'lucide-react' } from 'lucide-react'
import { Track, type ScreenShareCaptureOptions } from 'livekit-client' import { Track } from 'livekit-client'
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react' import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat' import type { HandQueueEntry } from '@/hooks/useChat'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions' import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
import { HandQueueMenu } from '@/components/room/HandQueueMenu' import { HandQueueMenu } from '@/components/room/HandQueueMenu'
import { SCREEN_SHARE_CAPTURE_OPTIONS } from '@/lib/screenShareOptions'
/**
* Опции захвата демонстрации экрана: `audio: true` — звук
* вкладки/экрана там, где браузер его отдаёт (Chrome/Edge — вкладка почти
* всегда, целый экран — только Windows); `selfBrowserSurface: 'exclude'`
* — не предлагать в списке
* источников собственную вкладку (зеркальный туннель самой конференции);
* `surfaceSwitching: 'include'` — разрешить переключать источник прямо во
* время демонстрации, не останавливая её; `systemAudio: 'include'` — не
* запрещать захват системного звука при выборе «весь экран». Вынесено в
* модульную константу — `useTrackToggle` держит `JSON.stringify(captureOptions)`
* в зависимостях внутреннего `useMemo`, инлайновый литерал был бы безвреден,
* но константа явнее фиксирует неизменность опций.
*/
const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
audio: true,
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include',
systemAudio: 'include',
}
interface RoomToolbarProps extends StageViewProps { interface RoomToolbarProps extends StageViewProps {
/** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */ /** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */
@@ -59,6 +39,8 @@ interface RoomToolbarProps extends StageViewProps {
pipSupported: boolean pipSupported: boolean
pipActive: boolean pipActive: boolean
onTogglePiP: () => void onTogglePiP: () => void
/** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся вовсе. */
handQueueEnabled: boolean
/** /**
* Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама * Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама
* решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity` * решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity`
@@ -71,6 +53,14 @@ interface RoomToolbarProps extends StageViewProps {
onLowerHandById: (identity: string) => void onLowerHandById: (identity: string) => void
/** Тулбар в оверлее полноэкранного режима — см. докстринг `RoomTopbar.overlayVisible`, тот же механизм. */ /** Тулбар в оверлее полноэкранного режима — см. докстринг `RoomTopbar.overlayVisible`, тот же механизм. */
overlayVisible?: boolean overlayVisible?: boolean
/**
* Пользователь нажал «Выйти» — вызывается ПЕРЕД тем, как `DisconnectButton`
* отключит комнату (обработчики в `mergeProps` вызываются цепочкой). Нужен
* `RoomPage`, чтобы отличить намеренный выход от разрыва: причина
* `CLIENT_INITIATED` приходит и от кнопки, и от livekit-client при заморозке
* вкладки — см. докстринг `handleDisconnected`.
*/
onLeave?: () => void
} }
/** /**
@@ -86,9 +76,11 @@ interface RoomToolbarProps extends StageViewProps {
* (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой * (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* экономии: поднять руку посреди разговора — действие со временем жизни в * экономии: поднять руку посреди разговора — действие со временем жизни в
* секунды, прятать его в шторку настроек означало бы делать его практически * секунды, прятать его в шторку настроек означало бы делать его практически
* недоступным с телефона. «Очередь» показывается только организатору — * недоступным с телефона. «Очередь» видна ЛЮБОМУ участнику (сессия
* встречается редко, но по той же причине оставлена в тулбаре, а не в * «28-hand-queue-for-all»: раньше только организатору), по той же причине
* шторке: организатору с телефона тоже нужно видеть очередь сразу. * оставлена в тулбаре, а не в шторке: любому участнику с телефона тоже нужно
* видеть очередь сразу. Обе кнопки целиком гасятся `handQueueEnabled`
* (`JoinOut.hand_queue_enabled`, отключаемый модуль в админке).
* Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку, * Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку,
* как демонстрация/мини-плеер) — по решению оператора вход в него должен * как демонстрация/мини-плеер) — по решению оператора вход в него должен
* быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один * быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один
@@ -107,6 +99,7 @@ export function RoomToolbar({
pipSupported, pipSupported,
pipActive, pipActive,
onTogglePiP, onTogglePiP,
handQueueEnabled,
handQueue, handQueue,
onRaiseHand, onRaiseHand,
onLowerHand, onLowerHand,
@@ -116,24 +109,26 @@ export function RoomToolbar({
hideOthers, hideOthers,
onHideOthersChange, onHideOthersChange,
overlayVisible, overlayVisible,
onLeave,
}: RoomToolbarProps) { }: RoomToolbarProps) {
const toast = useToast() const toast = useToast()
const isCompact = useIsCompactViewport() const isCompact = useIsCompactViewport()
const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant() const { localParticipant } = useLocalParticipant()
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity) const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
// Сколько кнопок реально видно на мобильном (демонстрация/мини-окно там // Сколько кнопок реально видно на мобильном (демонстрация/мини-окно там
// скрыты через CSS всегда, «Вид» не рендерится вовсе — см. докстринг) — // скрыты через CSS всегда, «Вид» не рендерится вовсе — см. докстринг) —
// считаем в JS, а не через CSS-селекторы вида `:nth-child`: у «Очереди» // считаем в JS, а не через CSS-селекторы вида `:nth-child`: у «Очереди»
// (только у организатора) в DOM есть своя обёртка `.tb-menu-wrap`, из-за // в DOM есть своя обёртка `.tb-menu-wrap`, из-за которой позиция остальных
// которой позиция остальных кнопок «плывёт», и подсчёт по структуре DOM // кнопок «плывёт», и подсчёт по структуре DOM был бы хрупким. От порога
// был бы хрупким. От порога зависит `.tb-wrap-grid` в room.css — ниже он // зависит `.tb-wrap-grid` в room.css — ниже он переключает перенос на
// переключает перенос на 2 строки с «как поместится» (flex-wrap) на // 2 строки с «как поместится» (flex-wrap) на равномерную сетку 4 колонки
// равномерную сетку 4 колонки (7 → 4+3, 8 → 4+4). // (7 → 4+3, 8 → 4+4). «Рука»/«Очередь» — обе теперь видны любому участнику
// (сессия «28-hand-queue-for-all»), их наличие зависит только от
// `handQueueEnabled`, не от роли.
const mobileButtonCount = const mobileButtonCount =
3 /* микрофон, камера, рука */ + 2 /* микрофон, камера */ +
(isOrganizer ? 1 : 0) /* очередь */ + (handQueueEnabled ? 2 : 0) /* рука, очередь */ +
1 /* настройки */ + 1 /* настройки */ +
(fullscreenSupported ? 1 : 0) + (fullscreenSupported ? 1 : 0) +
(chatVisible ? 1 : 0) + (chatVisible ? 1 : 0) +
@@ -205,23 +200,25 @@ export function RoomToolbar({
<span className="label">Демонстрация</span> <span className="label">Демонстрация</span>
</button> </button>
<button {handQueueEnabled && (
type="button" <button
className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`} type="button"
aria-pressed={handRaised} className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`}
aria-label={handRaised ? 'Опустить руку' : 'Поднять руку'} aria-pressed={handRaised}
onClick={() => (handRaised ? onLowerHand() : onRaiseHand())} aria-label={handRaised ? 'Опустить руку' : 'Поднять руку'}
> onClick={() => (handRaised ? onLowerHand() : onRaiseHand())}
<span className="icon-shell"> >
<Hand className="lucide" aria-hidden="true" /> <span className="icon-shell">
{handQueue.length > 0 && ( <Hand className="lucide" aria-hidden="true" />
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span> {handQueue.length > 0 && (
)} <span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
</span> )}
<span className="label">Рука</span> </span>
</button> <span className="label">Рука</span>
</button>
)}
<HandQueueMenu queue={handQueue} onLower={onLowerHandById} /> {handQueueEnabled && <HandQueueMenu queue={handQueue} onLower={onLowerHandById} />}
{!isCompact && ( {!isCompact && (
<StageViewMenu <StageViewMenu
@@ -306,7 +303,7 @@ export function RoomToolbar({
</button> </button>
)} )}
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции"> <DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции" onClick={onLeave}>
<span className="icon-shell"> <span className="icon-shell">
<LogOut className="lucide" aria-hidden="true" /> <LogOut className="lucide" aria-hidden="true" />
</span> </span>

View File

@@ -0,0 +1,29 @@
import type { ScreenShareCaptureOptions } from 'livekit-client'
/**
* Опции захвата демонстрации экрана: `audio: true` — звук
* вкладки/экрана там, где браузер его отдаёт (Chrome/Edge — вкладка почти
* всегда, целый экран — только Windows); `selfBrowserSurface: 'exclude'`
* — не предлагать в списке
* источников собственную вкладку (зеркальный туннель самой конференции);
* `surfaceSwitching: 'include'` — разрешить переключать источник прямо во
* время демонстрации, не останавливая её; `systemAudio: 'include'` — не
* запрещать захват системного звука при выборе «весь экран».
*
* Вынесено в модульную константу — `useTrackToggle` держит
* `JSON.stringify(captureOptions)` в зависимостях внутреннего `useMemo`,
* инлайновый литерал был бы безвреден, но константа явнее фиксирует
* неизменность опций.
*
* Живёт в `lib/`, а не рядом с тулбаром, потому что кнопок демонстрации теперь
* ДВЕ: в основном тулбаре (`RoomToolbar`) и в мини-окне (`RoomStage`,
* `PipScreenShareToggle`). Опции у них обязаны совпадать: обе кнопки управляют
* одной и той же публикацией, и разойдись они хотя бы в `audio`, демонстрация
* получалась бы разной в зависимости от того, откуда её запустили.
*/
export const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
audio: true,
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include',
systemAudio: 'include',
}

View File

@@ -0,0 +1,61 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { getRegistrationOptions } from '@/api/auth'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import '@/styles/legal.css'
/**
* Публичная страница регламента обработки персональных данных
* (`/legal/personal-data-consent`) — ссылка рядом с галочкой согласия на
* форме регистрации (`RegisterPage`). Текст и номер редакции берутся из
* того же публичного `GET /auth/registration-options`, которым пользуется
* форма регистрации — отдельного эндпоинта под это специально не заводили.
*
* Страница доступна ВСЕГДА, независимо от `consent_required`: если модуль
* выключен, регламент просто не обязателен для регистрации, но ссылка на
* него не должна вести в никуда — администратор мог оставить текст
* заполненным про запас или для внешней ссылки.
*/
export function ConsentPolicyPage() {
const { data, isLoading } = useQuery({
queryKey: ['auth', 'registration-options'],
queryFn: getRegistrationOptions,
})
return (
<div className="legal-shell">
<header className="legal-topbar">
<Link to="/lobby" className="brand-mark">
<LogoMark /> VidConf
</Link>
<ThemeToggle />
</header>
<main className="legal-main">
<article className="legal-card">
<h1>Регламент обработки персональных данных</h1>
{isLoading && <p className="legal-empty">Загрузка</p>}
{!isLoading && data && data.consent_text.trim() && (
<>
<span className="legal-version">Редакция {data.consent_version}</span>
<div className="legal-text">{data.consent_text}</div>
</>
)}
{!isLoading && data && !data.consent_text.trim() && (
<p className="legal-empty">
Регламент обработки персональных данных для этого инстанса ещё не заполнен
администратором.
</p>
)}
</article>
</main>
<AppFooter />
</div>
)
}

View File

@@ -146,6 +146,7 @@ export function JoinPage() {
title: resolved.title, title: resolved.title,
conferenceId: data.conference_id, conferenceId: data.conference_id,
chatEnabled: data.chat_enabled, chatEnabled: data.chat_enabled,
handQueueEnabled: data.hand_queue_enabled,
publishQualityCap: data.publish_quality_cap, publishQualityCap: data.publish_quality_cap,
stageMaxTiles: data.stage_max_tiles, stageMaxTiles: data.stage_max_tiles,
}, },

View File

@@ -43,6 +43,7 @@ export function LobbyPage() {
title: conference.title, title: conference.title,
conferenceId: conference.join.conference_id, conferenceId: conference.join.conference_id,
chatEnabled: conference.join.chat_enabled, chatEnabled: conference.join.chat_enabled,
handQueueEnabled: conference.join.hand_queue_enabled,
publishQualityCap: conference.join.publish_quality_cap, publishQualityCap: conference.join.publish_quality_cap,
stageMaxTiles: conference.join.stage_max_tiles, stageMaxTiles: conference.join.stage_max_tiles,
number: conference.number, number: conference.number,

View File

@@ -6,6 +6,8 @@ import { AuthLayout } from '@/components/auth/AuthLayout'
import { getRegistrationOptions, register } from '@/api/auth' import { getRegistrationOptions, register } from '@/api/auth'
import { ApiError, errorDetail } from '@/api/client' import { ApiError, errorDetail } from '@/api/client'
const CONSENT_POLICY_PATH = '/legal/personal-data-consent'
/** Текст предупреждения о несовпадении домена почты с эталонными. */ /** Текст предупреждения о несовпадении домена почты с эталонными. */
function domainMismatchMessage(domains: string[]): string { function domainMismatchMessage(domains: string[]): string {
const list = domains.map((domain) => `@${domain}`).join(' или ') const list = domains.map((domain) => `@${domain}`).join(' или ')
@@ -23,12 +25,19 @@ function domainMismatchMessage(domains: string[]): string {
* 400 `invalid_email_domain`. * 400 `invalid_email_domain`.
* После успешной регистрации показывает состояние «подтвердите почту» * После успешной регистрации показывает состояние «подтвердите почту»
* (письмо со ссылкой backend в dev-режиме печатает в консоль). * (письмо со ссылкой backend в dev-режиме печатает в консоль).
* Галочка согласия на обработку персональных данных (`consent_required`)
* показывается только при включённой настройке инстанса; ссылка рядом
* ведёт на публичную страницу регламента (`ConsentPolicyPage`), которая
* берёт тот же текст/версию из `registration-options`. Кнопка отправки
* заблокирована, пока галочка не отмечена, — сервер отдельно перепроверяет
* это же условие (`POST /auth/register` отвечает 400 `consent_required`).
*/ */
export function RegisterPage() { export function RegisterPage() {
const [name, setName] = useState('') const [name, setName] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [teamId, setTeamId] = useState('') const [teamId, setTeamId] = useState('')
const [consentAccepted, setConsentAccepted] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [emailDomainError, setEmailDomainError] = useState<string | null>(null) const [emailDomainError, setEmailDomainError] = useState<string | null>(null)
const [pending, setPending] = useState(false) const [pending, setPending] = useState(false)
@@ -41,6 +50,7 @@ export function RegisterPage() {
const teamChoiceEnabled = registrationOptions?.team_choice_enabled ?? false const teamChoiceEnabled = registrationOptions?.team_choice_enabled ?? false
const teams = registrationOptions?.teams ?? [] const teams = registrationOptions?.teams ?? []
const expectedEmailDomains = registrationOptions?.email_domains ?? [] const expectedEmailDomains = registrationOptions?.email_domains ?? []
const consentRequired = registrationOptions?.consent_required ?? false
/** Домен после «@» не совпадает (без учёта регистра) ни с одним эталонным — `null`, если сверять не с чем. */ /** Домен после «@» не совпадает (без учёта регистра) ни с одним эталонным — `null`, если сверять не с чем. */
function checkEmailDomain(value: string): string | null { function checkEmailDomain(value: string): string | null {
@@ -61,16 +71,25 @@ export function RegisterPage() {
const domainError = checkEmailDomain(email) const domainError = checkEmailDomain(email)
setEmailDomainError(domainError) setEmailDomainError(domainError)
if (domainError) return if (domainError) return
if (consentRequired && !consentAccepted) return
setPending(true) setPending(true)
try { try {
await register({ email, name_user: name, password, team_id: teamChoiceEnabled ? teamId || null : null }) await register({
email,
name_user: name,
password,
team_id: teamChoiceEnabled ? teamId || null : null,
consent_accepted: consentAccepted,
})
setRegisteredEmail(email) setRegisteredEmail(email)
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 409) { if (err instanceof ApiError && err.status === 409) {
setError('Пользователь с таким email уже зарегистрирован') setError('Пользователь с таким email уже зарегистрирован')
} else if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_email_domain') { } else if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_email_domain') {
setEmailDomainError(expectedEmailDomains.length > 0 ? domainMismatchMessage(expectedEmailDomains) : 'Регистрация с этим доменом почты недоступна') setEmailDomainError(expectedEmailDomains.length > 0 ? domainMismatchMessage(expectedEmailDomains) : 'Регистрация с этим доменом почты недоступна')
} else if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'consent_required') {
setError('Отметьте согласие на обработку персональных данных')
} else { } else {
setError('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова') setError('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
} }
@@ -185,7 +204,29 @@ export function RegisterPage() {
<p className="hint-msg">Минимум 8 символов, буквы и цифры</p> <p className="hint-msg">Минимум 8 символов, буквы и цифры</p>
)} )}
</div> </div>
<button type="submit" className="btn btn-primary btn-block btn-lg" disabled={pending}> {consentRequired && (
<label className="checkbox-row" style={{ marginBottom: 'var(--space-4)' }}>
<input
type="checkbox"
checked={consentAccepted}
onChange={(e) => setConsentAccepted(e.target.checked)}
/>
Согласен(на) на{' '}
<Link
to={CONSENT_POLICY_PATH}
target="_blank"
rel="noopener"
style={{ fontWeight: 700, textDecoration: 'underline' }}
>
обработку персональных данных
</Link>
</label>
)}
<button
type="submit"
className="btn btn-primary btn-block btn-lg"
disabled={pending || (consentRequired && !consentAccepted)}
>
{pending ? 'Регистрируем…' : 'Зарегистрироваться'} {pending ? 'Регистрируем…' : 'Зарегистрироваться'}
</button> </button>
</form> </form>

View File

@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { PictureInPicture2 } from 'lucide-react' import { PictureInPicture2 } from 'lucide-react'
import { LiveKitRoom, RoomAudioRenderer, usePersistentUserChoices } from '@livekit/components-react' import { LiveKitRoom, RoomAudioRenderer, usePersistentUserChoices } from '@livekit/components-react'
import type { RoomOptions } from 'livekit-client' import { DisconnectReason, type RoomOptions } from 'livekit-client'
import '@livekit/components-styles' import '@livekit/components-styles'
import '@/styles/room.css' import '@/styles/room.css'
import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences' import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences'
@@ -28,6 +28,23 @@ const FULLSCREEN_CONTROLS_AUTO_HIDE_MS = 4000
/** Полоса у нижнего края экрана (px) — наведение мыши в неё в полноэкранном режиме на десктопе показывает тулбар без клика. */ /** Полоса у нижнего края экрана (px) — наведение мыши в неё в полноэкранном режиме на десктопе показывает тулбар без клика. */
const FULLSCREEN_FOOTER_HOVER_ZONE_PX = 72 const FULLSCREEN_FOOTER_HOVER_ZONE_PX = 72
/**
* Сколько раз подряд страница возвращается в конференцию сама после разрыва,
* которого пользователь не просил. Ограничение — защита от бесконечного цикла
* «вошёл → сразу выбросило»: например, если конференция кончилась ровно в
* момент разрыва или сервер по какой-то причине не принимает участника.
* Исчерпав бюджет, страница ведёт себя как раньше — уводит в лобби.
*/
const AUTO_REJOIN_LIMIT = 5
/**
* Соединение, прожившее дольше этого времени, считается состоявшимся: бюджет
* авто-перезаходов обнуляется. Без этого редкие разрывы за длинную встречу
* (телефон засыпал пять раз за час) постепенно съели бы лимит и шестое
* пробуждение выбросило бы участника в лобби.
*/
const AUTO_REJOIN_BUDGET_RESET_MS = 30_000
interface RoomJoinState { interface RoomJoinState {
livekitUrl: string livekitUrl: string
token: string token: string
@@ -38,6 +55,8 @@ interface RoomJoinState {
number?: string number?: string
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */ /** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
chatEnabled?: boolean chatEnabled?: boolean
/** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся. */
handQueueEnabled?: boolean
/** /**
* Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`, * Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`,
* `instance_settings.media_limits`) — приезжают вместе с токеном, ДО * `instance_settings.media_limits`) — приезжают вместе с токеном, ДО
@@ -78,13 +97,88 @@ export function RoomPage() {
const [joinState, setJoinState] = useState<RoomJoinState | null>(initialState) const [joinState, setJoinState] = useState<RoomJoinState | null>(initialState)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Пользователь нажал «Выйти» — ЕДИНСТВЕННЫЙ признак, по которому уход со
// страницы считается намеренным. Отдельный флаг нужен потому, что причина
// `CLIENT_INITIATED` приходит не только от кнопки: livekit-client вешает
// обработчик на событие `freeze` (Page Lifecycle) и при заморозке вкладки
// сам зовёт `room.disconnect()` — с той же самой причиной. Android Chrome
// фоновую вкладку замораживает, так что без этого флага заснувший телефон
// выглядел бы ровно как «пользователь вышел» и уезжал бы в лобби.
const leaveRequestedRef = useRef(false)
// Когда установилось текущее соединение — чтобы отличить «разрыв после
// нормальной работы» от «выбрасывает сразу после входа» (см. AUTO_REJOIN_*).
const connectedAtRef = useRef(0)
const autoRejoinCountRef = useRef(0)
const handleLeaveClick = useCallback(() => {
leaveRequestedRef.current = true
}, [])
const handleConnected = useCallback(() => {
connectedAtRef.current = Date.now()
// Мы снова в комнате — намерение выйти, если оно откуда-то осталось
// невыполненным, к этому соединению уже не относится.
leaveRequestedRef.current = false
}, [])
// useCallback: стабильная ссылка на обработчик — иначе на каждом рендере // useCallback: стабильная ссылка на обработчик — иначе на каждом рендере
// RoomPage пересобирался бы объект-пропс `onDisconnected`, а с ним и // RoomPage пересобирался бы объект-пропс `onDisconnected`, а с ним и
// внутренний useEffect @livekit/components-react (он в списке зависимостей // внутренний useEffect @livekit/components-react (он в списке зависимостей
// хука useLiveKitRoom), лишний раз пересоздавая подписки на события Room. // хука useLiveKitRoom), лишний раз пересоздавая подписки на события Room.
const handleDisconnected = useCallback(() => { //
navigate(status === 'authenticated' ? '/lobby' : '/join') // Разрыв ≠ выход. До 0.0.27 любое отключение уводило со страницы, и на
}, [navigate, status]) // мобильном это ломало обычный сценарий: экран телефона гаснет, Chrome
// срезает фоновой вкладке ресурсы, ICE перестаёт отвечать — и LiveKit
// закрывает участника через 5 с после потери соединения
// (`PEER_CONNECTION_DISCONNECTED`, замерено на проде: 37 с после блокировки
// экрана, 23 с после сворачивания браузера). Восстановить сессию после
// этого нельзя — участника на сервере уже нет (`could not restart
// participant`), нужен полный повторный вход. livekit-client такой вход
// пытается сделать сам, но его бюджет повторов (10 попыток, ~44 с) в
// фоновой вкладке успевает сгореть — и тогда приходит `Disconnected`,
// после которого пользователь оказывался в лобби вместо конференции.
//
// Теперь непреднамеренный разрыв просто сбрасывает `joinState`, а дальше
// работает уже написанный путь авто-перезахода (эффект ниже): страница
// молча резолвит конференцию, берёт свежий токен и входит заново. Никакого
// второго механизма восстановления не появляется.
const handleDisconnected = useCallback(
(reason?: DisconnectReason) => {
const leaveForGood = () => navigate(status === 'authenticated' ? '/lobby' : '/join')
if (leaveRequestedRef.current) {
leaveForGood()
return
}
// Разрывы, после которых возвращаться некуда или нельзя: участника
// выгнал организатор, конференцию закрыли, либо этой же личностью вошли
// с другого устройства (перезаход устроил бы пинг-понг между ними).
if (
reason === DisconnectReason.PARTICIPANT_REMOVED ||
reason === DisconnectReason.ROOM_DELETED ||
reason === DisconnectReason.ROOM_CLOSED ||
reason === DisconnectReason.DUPLICATE_IDENTITY
) {
leaveForGood()
return
}
const sessionMs = connectedAtRef.current ? Date.now() - connectedAtRef.current : 0
if (sessionMs > AUTO_REJOIN_BUDGET_RESET_MS) {
autoRejoinCountRef.current = 0
}
if (autoRejoinCountRef.current >= AUTO_REJOIN_LIMIT) {
leaveForGood()
return
}
autoRejoinCountRef.current += 1
connectedAtRef.current = 0
setJoinState(null)
},
[navigate, status],
)
useEffect(() => { useEffect(() => {
if (joinState || !slug) return if (joinState || !slug) return
@@ -116,6 +210,7 @@ export function RoomPage() {
title: info.title, title: info.title,
conferenceId: result.conference_id, conferenceId: result.conference_id,
chatEnabled: result.chat_enabled, chatEnabled: result.chat_enabled,
handQueueEnabled: result.hand_queue_enabled,
publishQualityCap: result.publish_quality_cap, publishQualityCap: result.publish_quality_cap,
stageMaxTiles: result.stage_max_tiles, stageMaxTiles: result.stage_max_tiles,
}) })
@@ -168,10 +263,16 @@ export function RoomPage() {
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()` // Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
// на каждой плитке сцены (см. `RoomParticipantTile`). // на каждой плитке сцены (см. `RoomParticipantTile`). Выключенный модуль
// (`handQueueEnabled=false`) гасит и эти значки — иначе на плитке мог бы
// повиснуть индикатор от руки, поднятой до отключения модуля (сервер
// хранит очередь в Redis независимо от тоггла, см. `services/hand_queue.py`).
const raisedHandIdentities = useMemo( const raisedHandIdentities = useMemo(
() => new Set(chat.handQueue.map((entry) => entry.identity)), () =>
[chat.handQueue], joinState?.handQueueEnabled
? new Set(chat.handQueue.map((entry) => entry.identity))
: new Set<string>(),
[chat.handQueue, joinState?.handQueueEnabled],
) )
// Корневой контейнер комнаты — цель для fullscreen и источник video-элемента // Корневой контейнер комнаты — цель для fullscreen и источник video-элемента
@@ -362,6 +463,7 @@ export function RoomPage() {
audio={false} audio={false}
video={false} video={false}
options={roomOptions} options={roomOptions}
onConnected={handleConnected}
onDisconnected={handleDisconnected} onDisconnected={handleDisconnected}
> >
{/* Звук комнаты рендерится ЗДЕСЬ, а не внутри `RoomStage`, и ровно {/* Звук комнаты рендерится ЗДЕСЬ, а не внутри `RoomStage`, и ровно
@@ -435,6 +537,7 @@ export function RoomPage() {
pipSupported={pip.supported} pipSupported={pip.supported}
pipActive={pip.active} pipActive={pip.active}
onTogglePiP={pip.toggle} onTogglePiP={pip.toggle}
handQueueEnabled={Boolean(joinState.handQueueEnabled)}
handQueue={chat.handQueue} handQueue={chat.handQueue}
onRaiseHand={chat.raiseHand} onRaiseHand={chat.raiseHand}
onLowerHand={() => chat.lowerHand()} onLowerHand={() => chat.lowerHand()}
@@ -444,6 +547,7 @@ export function RoomPage() {
hideOthers={hideOthers} hideOthers={hideOthers}
onHideOthersChange={setHideOthers} onHideOthersChange={setHideOthers}
overlayVisible={fullscreenControlsVisible} overlayVisible={fullscreenControlsVisible}
onLeave={handleLeaveClick}
/> />
</div> </div>
{settingsOpen && ( {settingsOpen && (

View File

@@ -0,0 +1,48 @@
/*
* VidConf — стили публичной страницы регламента обработки персональных данных.
* Топбар/оболочка — по образцу `.join-shell`/`.join-topbar` (join.css):
* страница публичная (открывается до регистрации, без сессии), поэтому не
* использует `.page-shell`/`ShellTopbar`. Карточка шире, чем `.flow-card`
* (440px рассчитан на форму, не на читаемый текст документа).
*/
.legal-shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--color-bg); }
.legal-topbar { display: flex; align-items: center; justify-content: space-between; padding: var(--space-5) var(--space-8); }
.legal-main {
flex: 1;
display: flex;
justify-content: center;
padding: var(--space-6) var(--space-8) var(--space-16);
}
.legal-card {
width: 100%;
max-width: 760px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-2xl);
padding: var(--space-8);
}
.legal-card h1 { font: var(--text-h1); color: var(--color-ink-900); margin: 0 0 var(--space-2); }
.legal-version {
display: inline-block;
font: var(--text-mono-sm);
color: var(--color-ink-500);
background: var(--color-bg-alt);
border-radius: var(--radius-full);
padding: 2px 12px;
margin-bottom: var(--space-6);
}
.legal-text {
font: var(--text-body);
color: var(--color-ink-700);
white-space: pre-wrap;
line-height: 1.6;
}
.legal-empty { font: var(--text-body); color: var(--color-ink-500); }

View File

@@ -133,18 +133,40 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
* `.stage-tiles` в основном окне) плюс подстраховочный `min-height`, чтобы * `.stage-tiles` в основном окне) плюс подстраховочный `min-height`, чтобы
* плитка не схлопывалась, если видео ещё не выдало реальные размеры. * плитка не схлопывалась, если видео ещё не выдало реальные размеры.
*/ */
.room-single-tile { position: relative; flex: 1; min-height: 0; display: flex; } .room-single-tile {
position: relative;
flex: 1;
min-height: 0;
display: flex;
/* Ширина ряда кнопок мини-плеера (`.room-pip-controls`): две круглые кнопки
по 36px плюс зазор. Живёт на общем предке, а не на самом ряду, потому что
от неё считается и отступ плашки с именем участника — иначе подпись
(«Второй участник — демонстрация экрана») уезжает ПОД кнопки. Появится
третья кнопка — правится одно число, а не два места. */
--room-pip-controls-width: calc(2 * 36px + 0.375rem);
}
.room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; } .room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; }
/* Библиотечная плашка с именем прижата к обоим нижним углам (`left: 4px;
right: 4px`) и потому начинается ровно там, где лежат наши кнопки. Сдвигаем
её содержимое вправо на ширину ряда — сама плашка прозрачная, подпись внутри
со своим фоном, так что двигать надо именно отступ, а не `left`. */
.room-single-tile .lk-participant-metadata { padding-left: var(--room-pip-controls-width); }
/* Кнопка микрофона мини-плеера (задача A5) — своего тулбара в PiP нет, /* Кнопки мини-плеера (микрофон — задача A5, демонстрация экрана — 0.0.29):
кнопка лежит поверх единственной плитки левым нижним углом (правый занят своего тулбара в PiP нет, кнопки лежат поверх единственной плитки левым
штатным `.lk-focus-toggle-button`/нашей булавкой закрепления — тут её нет, нижним углом (правый занят штатным `.lk-focus-toggle-button`/нашей булавкой
в PiP закреплять нечего, но геометрия зеркалит `.room-pin-toggle`). */ закрепления — тут её нет, в PiP закреплять нечего, но геометрия зеркалит
.room-pip-mic-toggle { `.room-pin-toggle`). Ряд, а не две абсолютно спозиционированные кнопки:
так добавление третьей не потребует пересчёта координат. */
.room-pip-controls {
position: absolute; position: absolute;
left: 0.5rem; left: 0.5rem;
bottom: 0.5rem; bottom: 0.5rem;
z-index: 5; z-index: 5;
display: flex;
gap: 0.375rem;
}
.room-pip-btn {
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 50%; border-radius: 50%;
@@ -152,12 +174,16 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--color-room-mic-on);
color: #10331f;
cursor: pointer; cursor: pointer;
} }
.room-pip-mic-toggle svg { width: 18px; height: 18px; } .room-pip-btn svg { width: 18px; height: 18px; }
.room-pip-mic-toggle { background: var(--color-room-mic-on); color: #10331f; }
.room-pip-mic-toggle.is-off { background: var(--color-room-danger-bg); color: #fff; } .room-pip-mic-toggle.is-off { background: var(--color-room-danger-bg); color: #fff; }
/* Демонстрация — «выключено» это нейтральное состояние (не тревога, в отличие
от снятого микрофона), поэтому неактивная кнопка тёмная под цвет плитки, а
активная подсвечена тем же зелёным, что включённый микрофон. */
.room-pip-share-toggle { background: var(--color-room-tile-hover); color: var(--color-room-text-primary); }
.room-pip-share-toggle.is-on { background: var(--color-room-mic-on); color: #10331f; }
/* /*
* ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ---------- * ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ----------