diff --git a/backend/api/admin.py b/backend/api/admin.py index a653610..279e1f3 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -475,6 +475,7 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) - consent_required=cfg.consent_required, consent_policy_text=cfg.consent_policy_text, consent_policy_version=cfg.consent_policy_version, + device_check_enabled=cfg.device_check_enabled, ) diff --git a/backend/api/public.py b/backend/api/public.py new file mode 100644 index 0000000..9965aaa --- /dev/null +++ b/backend/api/public.py @@ -0,0 +1,21 @@ +"""Роутер публичных настроек клиента — доступен без аутентификации.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_session +from schemas.public import PublicSettingsOut +from services.instance_settings import InstanceSettingsService + +router = APIRouter(prefix="/api/v1/public", tags=["public"]) + + +@router.get("/settings", response_model=PublicSettingsOut) +async def public_settings( + session: Annotated[AsyncSession, Depends(get_session)], +) -> PublicSettingsOut: + """Флаги инстанса, нужные публичным страницам логина/входа гостя до аутентификации.""" + cfg = await InstanceSettingsService(session).get() + return PublicSettingsOut(device_check_enabled=cfg.device_check_enabled) diff --git a/backend/core/plugins/config.py b/backend/core/plugins/config.py index eeb9a9b..6329fe5 100644 --- a/backend/core/plugins/config.py +++ b/backend/core/plugins/config.py @@ -133,3 +133,8 @@ class InstanceConfig(BaseModel): consent_required: bool = False consent_policy_text: str = "" consent_policy_version: int = 1 + # Проверка устройств на входе (сессия 33): запрос доступа к микрофону/камере + # + превью камеры на странице логина и в карточке «Как вас зовут?» (JoinPage). + # Дефолт False сохраняет поведение существующих инсталляций — см. + # `services/instance_settings.py`. + device_check_enabled: bool = False diff --git a/backend/main.py b/backend/main.py index 1783c6a..ddf3497 100644 --- a/backend/main.py +++ b/backend/main.py @@ -16,6 +16,7 @@ from api.health import router as health_router from api.livekit_webhook import router as livekit_webhook_router from api.metrics import prometheus_latency_middleware from api.metrics import router as metrics_router +from api.public import router as public_router from api.teams import router as teams_router from api.users import router as users_router from core.config import get_settings @@ -76,6 +77,7 @@ def create_app() -> FastAPI: app.middleware("http")(prometheus_latency_middleware) app.include_router(health_router) app.include_router(metrics_router) + app.include_router(public_router) app.include_router(auth_router) app.include_router(users_router) app.include_router(teams_router) diff --git a/backend/schemas/admin.py b/backend/schemas/admin.py index e7f4d8f..e07784c 100644 --- a/backend/schemas/admin.py +++ b/backend/schemas/admin.py @@ -168,6 +168,8 @@ class SettingsOut(BaseModel): consent_required: bool consent_policy_text: str consent_policy_version: int + # Проверка устройств на входе (сессия 33) — см. `core/plugins/config.py::InstanceConfig`. + device_check_enabled: bool class TestEmailIn(BaseModel): diff --git a/backend/schemas/public.py b/backend/schemas/public.py new file mode 100644 index 0000000..72eeb9e --- /dev/null +++ b/backend/schemas/public.py @@ -0,0 +1,18 @@ +"""Схемы публичного эндпоинта настроек клиента (`GET /public/settings`).""" + +from pydantic import BaseModel + + +class PublicSettingsOut(BaseModel): + """Настройки инстанса, нужные клиенту ДО аутентификации. + + Общая точка для флагов, которые должны быть на руках у страницы логина + и гостевой карточки входа (`LoginPage`/`JoinPage`) — обе публичные, + `GET /admin/settings` им недоступен (только для админа). Отдельно от + `GET /auth/registration-options`: тот про опции конкретно карточки + регистрации, а не про настройки инстанса в целом (сессия 33). + """ + + # Проверка устройств на входе (сессия 33) — см. + # `core/plugins/config.py::InstanceConfig.device_check_enabled`. + device_check_enabled: bool diff --git a/backend/services/instance_settings.py b/backend/services/instance_settings.py index 53feeaa..9f655ea 100644 --- a/backend/services/instance_settings.py +++ b/backend/services/instance_settings.py @@ -55,6 +55,7 @@ _KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain" _KEY_CONTACT_EMAIL = "contact_email" _KEY_MEDIA_LIMITS = "media_limits" _KEY_CONSENT_POLICY = "consent_policy" +_KEY_DEVICE_CHECK = "device_check" BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = ( _KEY_CHAT, @@ -79,6 +80,7 @@ _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "d `update()` значение переписывается в новую форму (см. `update`).""" _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_DEVICE_CHECK_VALUE = {"enabled": False} DEFAULT_CONSENT_POLICY_TEXT = """Это типовой шаблон для предварительной демонстрации. Текст не проходил проверку юриста и не может использоваться как окончательная редакция без такой проверки. Администратор обязан заменить плейсхолдеры в квадратных скобках и, при необходимости, весь текст — под свою организацию и юрисдикцию. @@ -151,6 +153,7 @@ class SettingsUpdateIn(BaseModel): stage_max_tiles: StageMaxTiles | None = None consent_required: bool | None = None consent_policy_text: str | None = None + device_check_enabled: bool | None = None class BootstrapOverrides(BaseModel): @@ -203,6 +206,7 @@ def build_bootstrap_defaults( _KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE), _KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE), _KEY_CONSENT_POLICY: dict(_DEFAULT_CONSENT_POLICY_VALUE), + _KEY_DEVICE_CHECK: dict(_DEFAULT_DEVICE_CHECK_VALUE), } if overrides is None: return defaults @@ -337,6 +341,10 @@ class InstanceSettingsService: cfg.hand_queue = HandQueueConfig(enabled=patch.hand_queue_enabled) await self._set(_KEY_HAND_QUEUE, cfg.hand_queue.model_dump(mode="json")) + if patch.device_check_enabled is not None: + cfg.device_check_enabled = patch.device_check_enabled + await self._set(_KEY_DEVICE_CHECK, {"enabled": patch.device_check_enabled}) + if patch.registration_team_choice is not None: cfg.registration_team_choice = patch.registration_team_choice await self._set( @@ -605,4 +613,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig: consent_policy_version=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get( "version", 1 ), + device_check_enabled=rows.get(_KEY_DEVICE_CHECK, _DEFAULT_DEVICE_CHECK_VALUE).get( + "enabled", False + ), ) diff --git a/backend/tests/test_instance_settings.py b/backend/tests/test_instance_settings.py index 671b889..47dceba 100644 --- a/backend/tests/test_instance_settings.py +++ b/backend/tests/test_instance_settings.py @@ -75,6 +75,7 @@ _MANAGED_KEYS = ( "contact_email", "media_limits", "consent_policy", + "device_check", ) @@ -133,6 +134,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults( "contact_email", "media_limits", "consent_policy", + "device_check", } cfg = await service.get() assert cfg.transcriber.provider == "faster_whisper_cpu" @@ -159,6 +161,9 @@ async def test_ensure_bootstrapped_imports_yaml_defaults( assert cfg.consent_required is False assert cfg.consent_policy_text != "" assert cfg.consent_policy_version == 1 + # Проверка устройств на входе (сессия 33) — выключена по умолчанию, + # существующие инсталляции не должны молча начать спрашивать доступ. + assert cfg.device_check_enabled is False async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits( @@ -193,6 +198,22 @@ async def test_update_hand_queue_enabled( assert cfg.hand_queue.enabled is True +async def test_update_device_check_enabled( + db_session: AsyncSession, clean_instance_settings: None +) -> None: + service = InstanceSettingsService(db_session) + await service.ensure_bootstrapped(PLUGINS_YAML) + + cfg = await service.update(SettingsUpdateIn(device_check_enabled=True)) + assert cfg.device_check_enabled is True + + cfg = await service.get() + assert cfg.device_check_enabled is True + + cfg = await service.update(SettingsUpdateIn(device_check_enabled=False)) + assert cfg.device_check_enabled is False + + @pytest.mark.parametrize( ("preset", "chat_enabled", "ai_enabled", "ai_level"), [ diff --git a/backend/tests/test_public_api.py b/backend/tests/test_public_api.py new file mode 100644 index 0000000..74853cd --- /dev/null +++ b/backend/tests/test_public_api.py @@ -0,0 +1,43 @@ +"""Тесты публичного эндпоинта настроек клиента (`/api/v1/public/settings`, сессия 33).""" + +import httpx +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession + +from services.instance_settings import InstanceSettingsService, SettingsUpdateIn + + +@pytest_asyncio.fixture(autouse=True) +async def _reset_device_check(db_session: AsyncSession) -> None: + """Сбросить `device_check_enabled` перед каждым тестом — общая dev-БД не изолирована + от ручных правок администратора (та же дисциплина, что и `_reset_registration_gating` + в `test_auth.py`); `db_session` не коммитится в реальную БД, см. `conftest.py`.""" + await InstanceSettingsService(db_session).update(SettingsUpdateIn(device_check_enabled=False)) + + +async def test_public_settings_disabled_by_default( + client: httpx.AsyncClient, db_session: AsyncSession +) -> None: + response = await client.get("/api/v1/public/settings") + assert response.status_code == 200, response.text + assert response.json() == {"device_check_enabled": False} + + +async def test_public_settings_reflects_enabled( + client: httpx.AsyncClient, db_session: AsyncSession +) -> None: + await InstanceSettingsService(db_session).update(SettingsUpdateIn(device_check_enabled=True)) + await db_session.commit() + + response = await client.get("/api/v1/public/settings") + assert response.status_code == 200, response.text + assert response.json() == {"device_check_enabled": True} + + +async def test_public_settings_requires_no_auth( + client: httpx.AsyncClient, db_session: AsyncSession +) -> None: + """Эндпоинт публичный — работает без заголовка `Authorization`, как и обязан + (обе страницы, которым он нужен, доступны до входа в систему).""" + response = await client.get("/api/v1/public/settings") + assert response.status_code == 200, response.text diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 9bb1cb7..898eaa0 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -51,6 +51,9 @@ export interface SettingsOut { consent_policy_text: string /** Номер редакции текста — растёт при каждом изменении `consent_policy_text`. */ consent_policy_version: number + /** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону + * и превью камеры на странице логина и в карточке «Как вас зовут?». */ + device_check_enabled: boolean } /** Тело частичного обновления настроек инстанса — все поля опциональны. */ @@ -74,6 +77,7 @@ export interface SettingsUpdateIn { /** Включение с пустым текстом регламента — backend отвечает 400. */ consent_required?: boolean consent_policy_text?: string + device_check_enabled?: boolean } /** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */ diff --git a/frontend/src/api/public.ts b/frontend/src/api/public.ts new file mode 100644 index 0000000..1ac5be9 --- /dev/null +++ b/frontend/src/api/public.ts @@ -0,0 +1,13 @@ +/** Публичные настройки инстанса — доступны без аутентификации (`GET /public/settings`). */ +import { apiRequest } from '@/api/client' + +/** Флаги, нужные странице логина и гостевой карточке входа до аутентификации. */ +export interface PublicSettingsOut { + /** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону + * и превью камеры на LoginPage/JoinPage. */ + device_check_enabled: boolean +} + +export async function getPublicSettings(): Promise { + return apiRequest('/public/settings', { skipAuthRefresh: true }) +} diff --git a/frontend/src/components/admin/AdminSettingsTab.tsx b/frontend/src/components/admin/AdminSettingsTab.tsx index 6c18db1..eeccb78 100644 --- a/frontend/src/components/admin/AdminSettingsTab.tsx +++ b/frontend/src/components/admin/AdminSettingsTab.tsx @@ -69,6 +69,7 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { const [chatEnabled, setChatEnabled] = useState(data.chat_enabled) const [handQueueEnabled, setHandQueueEnabled] = useState(data.hand_queue_enabled) + const [deviceCheckEnabled, setDeviceCheckEnabled] = useState(data.device_check_enabled) const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled) const [aiLevel, setAiLevel] = useState(data.ai_level) const [recipients, setRecipients] = useState(data.summary_recipients) @@ -140,6 +141,7 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { const payload: SettingsUpdateIn = {} if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled if (handQueueEnabled !== data.hand_queue_enabled) payload.hand_queue_enabled = handQueueEnabled + if (deviceCheckEnabled !== data.device_check_enabled) payload.device_check_enabled = deviceCheckEnabled if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled if (aiLevel !== data.ai_level) payload.ai_level = aiLevel if (recipients !== data.summary_recipients) payload.summary_recipients = recipients @@ -200,6 +202,26 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { +
+
+ Проверка устройств на входе + + Запрос доступа к камере и микрофону и превью камеры на странице входа и в + карточке «Как вас зовут?» — чтобы разрешение не выскакивало уже внутри + конференции. Микрофон и камера в самой конференции по-прежнему выключены при + входе + +
+ +
+
Транскрибация и суммаризация (AI) diff --git a/frontend/src/components/auth/DeviceCheckCard.tsx b/frontend/src/components/auth/DeviceCheckCard.tsx new file mode 100644 index 0000000..2f9adcb --- /dev/null +++ b/frontend/src/components/auth/DeviceCheckCard.tsx @@ -0,0 +1,90 @@ +import type { RefObject } from 'react' +import { Mic, MicOff, Video, VideoOff } from 'lucide-react' +import type { DeviceCheckStatus } from '@/hooks/useDeviceCheckAccess' +import '@/styles/device-check.css' + +interface DeviceCheckCardProps { + videoRef: RefObject + videoStatus: DeviceCheckStatus + audioStatus: DeviceCheckStatus + /** `useDeviceCheckAccess().hint` — `null`, пока отказа нет (см. докстринг хука). */ + hint: string | null + /** Одновременно и «войти с камерой/микрофоном включёнными», и (для видео) + * реальное состояние превью — см. докстринг `useDeviceCheckAccess`. */ + videoEnabled: boolean + audioEnabled: boolean + onToggleVideo: () => void + onToggleAudio: () => void +} + +/** + * Превью камеры + переключатели «микрофон/камера» — окошко превью и кнопки + * составляют одну композицию (кнопки не шире окошка, см. `device-check.css`). + * Кнопки без подписи — только пиктограмма (иначе не умещаются под окошком + * шириной в половину карточки); машиночитаемое состояние — `aria-label`. + * Кликабельны только после реального разрешения (`granted`) — до этого + * непонятно, что вообще включать. + * + * `
+
-
+
diff --git a/frontend/src/pages/RoomPage.tsx b/frontend/src/pages/RoomPage.tsx index 4ac3eca..dd7dc48 100644 --- a/frontend/src/pages/RoomPage.tsx +++ b/frontend/src/pages/RoomPage.tsx @@ -69,6 +69,15 @@ interface RoomJoinState { */ publishQualityCap?: PublishQualityCap stageMaxTiles?: number + /** + * Выбор пользователя на превью JoinPage (сессия 33, «проверка устройств на + * входе») — войти с уже включённым микрофоном/камерой. Отсутствует (→ + * `false`) для авторизованного входа мимо гостевой карточки, при + * выключенном модуле и при переподключении после F5 (эффект ниже) — + * тогда действует прежнее поведение `audio={false} video={false}` (0.0.15). + */ + enterWithVideo?: boolean + enterWithAudio?: boolean } /** @@ -460,8 +469,8 @@ export function RoomPage() { serverUrl={joinState.livekitUrl} token={joinState.token} connect - audio={false} - video={false} + audio={Boolean(joinState.enterWithAudio)} + video={Boolean(joinState.enterWithVideo)} options={roomOptions} onConnected={handleConnected} onDisconnected={handleDisconnected} diff --git a/frontend/src/styles/device-check.css b/frontend/src/styles/device-check.css new file mode 100644 index 0000000..422dd85 --- /dev/null +++ b/frontend/src/styles/device-check.css @@ -0,0 +1,75 @@ +/* + * VidConf — превью камеры на входе (сессия 33, `JoinPage`; LoginPage + * превью не показывает — см. `DeviceCheckCard` не используется там). + * Свой файл: компонент самодостаточный, стили ему не нужны нигде за + * пределами компонента. + */ + +.device-check { margin: 0 auto var(--space-5); } + +/* Окошко превью и кнопки под ним — одна композиция шириной в половину + карточки (`.flow-card`, куда компонент вложен): проценты сами масштабируются + от фактической ширины родителя без хардкода пикселей. */ +.device-check-inner { width: 50%; margin: 0 auto; } + +.device-check-frame { + position: relative; + width: 100%; + aspect-ratio: 4 / 3; + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--color-bg-alt); + border: 1px solid var(--color-border); +} + +.device-check-video { + width: 100%; + height: 100%; + object-fit: cover; + /* Своё видео принято показывать зеркально — так человек видит себя + привычно, как в зеркале, а не «как его видят другие». */ + transform: scaleX(-1); +} + +.device-check-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-ink-400); +} +.device-check-placeholder .lucide { width: 26px; height: 26px; } + +/* Кнопки без подписи, только пиктограммы — их совместная ширина равна + ширине окошка превью (тот же `.device-check-inner`), за края не выступают. */ +.device-check-toggles { + display: flex; + gap: var(--space-2); + margin-top: var(--space-2); +} + +.device-check-toggle { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 8px 0; + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + background: var(--color-bg-alt); + color: var(--color-ink-500); + cursor: pointer; +} +.device-check-toggle .lucide { width: 18px; height: 18px; } +.device-check-toggle.is-on { + background: var(--color-accent); + border-color: transparent; + color: var(--color-accent-text); +} +.device-check-toggle:disabled { opacity: 0.5; cursor: not-allowed; } + +.device-check-hint { + text-align: center; + margin-top: var(--space-3); +}