Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 413789ba22 | |||
| c4721fcf99 | |||
| 09215da22a | |||
| 25ffd9e678 | |||
| e55a6aebe7 | |||
| 4b92f89efe | |||
| b11feac529 | |||
| e07217657f | |||
| 35a1455645 |
@@ -98,7 +98,7 @@ SMTP_TIMEOUT_S=30
|
|||||||
# --- Версия инстанса (релиз v0.0.1) ---
|
# --- Версия инстанса (релиз v0.0.1) ---
|
||||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||||
# установке/обновлении — руками менять не нужно.
|
# установке/обновлении — руками менять не нужно.
|
||||||
VIDCONF_VERSION=0.0.6
|
VIDCONF_VERSION=0.0.8
|
||||||
|
|
||||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||||
|
|||||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -3,6 +3,50 @@
|
|||||||
Формат основан на [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.8] — 2026-07-27
|
||||||
|
|
||||||
|
Админка: несколько mail-доменов для регистрации, фильтр и поиск по команде в
|
||||||
|
списке пользователей, исправлен баг сохранения настроек.
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
- Сохранение любой настройки инстанса блокировалось ошибкой «уровень AI
|
||||||
|
недоступен», даже если AI-модуль (транскрибация/суммаризация) выключен и
|
||||||
|
пользователь уровень не трогал — фронт отправлял в `PUT /admin/settings`
|
||||||
|
весь набор полей целиком, включая уже сохранённый `ai_level`. Теперь
|
||||||
|
доступность уровня проверяется только при его фактической смене; фронт
|
||||||
|
отправляет в запросе лишь реально изменённые поля.
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- «Эталон mail-домена» в настройках инстанса поддерживает несколько
|
||||||
|
доменов вместо одного — регистрация проходит с email с любого домена из
|
||||||
|
списка. Старое значение из БД (один домен) читается без миграции и
|
||||||
|
переписывается в новый формат при первом же сохранении.
|
||||||
|
- В списке пользователей админки — вкладки «Активные»/«Заблокированные»/
|
||||||
|
«Все» перед полем поиска.
|
||||||
|
- Поиск по пользователям учитывает и название команды — пользователи без
|
||||||
|
команды по-прежнему остаются в общей выдаче.
|
||||||
|
|
||||||
|
## [0.0.7] — 2026-07-27
|
||||||
|
|
||||||
|
Настройки устройств: выбор аудиовывода и компактная шторка на мобильном.
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- Выбор устройства аудиовывода (динамики/наушники/Bluetooth-гарнитура) в
|
||||||
|
«Настройках устройств» рядом с микрофоном и камерой. Выбор переживает
|
||||||
|
перезаход в комнату. Список показывается только там, где браузер умеет
|
||||||
|
им управлять (Android Chrome и другие Chromium на десктопе/мобильном);
|
||||||
|
в iOS Safari поддержки нет вовсе — вместо списка короткая подсказка, что
|
||||||
|
переключение звука там на стороне системы.
|
||||||
|
- На мобильной ширине «Настройки устройств» открываются компактной шторкой
|
||||||
|
снизу вместо модалки по центру экрана — закрывается свайпом вниз, тапом
|
||||||
|
вне панели или Escape. На десктопе поведение не изменилось.
|
||||||
|
|
||||||
|
### Примечания по эксплуатации
|
||||||
|
- Управлять маршрутизацией звука (принудительно включить громкую связь или
|
||||||
|
перекинуть звук на Bluetooth) из браузера нельзя в принципе — это делает
|
||||||
|
ОС, веб-страница может только выбрать устройство вывода там, где браузер
|
||||||
|
это позволяет (`setSinkId`).
|
||||||
|
|
||||||
## [0.0.6] — 2026-07-27
|
## [0.0.6] — 2026-07-27
|
||||||
|
|
||||||
Управление фокусом основного окна конференции: участника можно закрепить, а
|
Управление фокусом основного окна конференции: участника можно закрепить, а
|
||||||
@@ -162,6 +206,8 @@
|
|||||||
|
|
||||||
Первоначальная версия VidConf.
|
Первоначальная версия VidConf.
|
||||||
|
|
||||||
|
[0.0.8]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.8
|
||||||
|
[0.0.7]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.7
|
||||||
[0.0.6]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.6
|
[0.0.6]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.6
|
||||||
[0.0.5]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.5
|
[0.0.5]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.5
|
||||||
[0.0.4]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.4
|
[0.0.4]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.4
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
@@ -174,12 +174,16 @@ async def send_conference_invitations(
|
|||||||
async def list_users(
|
async def list_users(
|
||||||
admin: Annotated[User, Depends(require_admin)],
|
admin: Annotated[User, Depends(require_admin)],
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
status: Annotated[Literal["active", "blocked"] | None, Query()] = None,
|
||||||
q: Annotated[str | None, Query()] = None,
|
q: Annotated[str | None, Query()] = None,
|
||||||
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
|
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
|
||||||
offset: Annotated[int, Query(ge=0)] = 0,
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
) -> AdminUserListOut:
|
) -> AdminUserListOut:
|
||||||
"""Список всех пользователей инстанса с текстовым поиском по email/имени."""
|
"""Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без
|
||||||
rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset)
|
параметра — все) и текстовый поиск по email/имени/названию команды."""
|
||||||
|
rows, total = await AdminUserRepository(session).list_paginated(
|
||||||
|
status=status, q=q, limit=limit, offset=offset
|
||||||
|
)
|
||||||
media_root = _media_root()
|
media_root = _media_root()
|
||||||
items = [
|
items = [
|
||||||
_to_admin_user_out(user, team_name=team_name, media_root=media_root)
|
_to_admin_user_out(user, team_name=team_name, media_root=media_root)
|
||||||
@@ -460,7 +464,7 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
|
|||||||
display_timezone=cfg.display_timezone,
|
display_timezone=cfg.display_timezone,
|
||||||
registration_team_choice=cfg.registration_team_choice,
|
registration_team_choice=cfg.registration_team_choice,
|
||||||
registration_email_domain_enabled=cfg.registration_email_domain_enabled,
|
registration_email_domain_enabled=cfg.registration_email_domain_enabled,
|
||||||
registration_email_domain=cfg.registration_email_domain,
|
registration_email_domains=cfg.registration_email_domains,
|
||||||
contact_email_enabled=cfg.contact_email_enabled,
|
contact_email_enabled=cfg.contact_email_enabled,
|
||||||
contact_email=cfg.contact_email,
|
contact_email=cfg.contact_email,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,17 +53,17 @@ async def registration_options(
|
|||||||
|
|
||||||
Список команд отдаётся только при включённой настройке инстанса
|
Список команд отдаётся только при включённой настройке инстанса
|
||||||
`registration_team_choice` — иначе пустой массив (справочник команд не
|
`registration_team_choice` — иначе пустой массив (справочник команд не
|
||||||
раскрывается, пока выбор выключен). `email_domain` — эталонный домен при
|
раскрывается, пока выбор выключен). `email_domains` — эталонные домены при
|
||||||
включённой настройке `registration_email_domain`, иначе `None`.
|
включённой настройке `registration_email_domain`, иначе пустой список.
|
||||||
"""
|
"""
|
||||||
cfg = await InstanceSettingsService(session).get()
|
cfg = await InstanceSettingsService(session).get()
|
||||||
teams: list[RegistrationTeamOptionOut] = []
|
teams: list[RegistrationTeamOptionOut] = []
|
||||||
if cfg.registration_team_choice:
|
if cfg.registration_team_choice:
|
||||||
items, _ = await TeamRepository(session).list_all()
|
items, _ = await TeamRepository(session).list_all()
|
||||||
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_domain = cfg.registration_email_domain if cfg.registration_email_domain_enabled else None
|
email_domains = cfg.registration_email_domains if cfg.registration_email_domain_enabled else []
|
||||||
return RegistrationOptionsOut(
|
return RegistrationOptionsOut(
|
||||||
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domain=email_domain
|
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domains=email_domains
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ class InstanceConfig(BaseModel):
|
|||||||
# — см. `services/instance_settings.py`.
|
# — см. `services/instance_settings.py`.
|
||||||
registration_team_choice: bool = False
|
registration_team_choice: bool = False
|
||||||
# Верификация регистрирующихся по домену email: при включении
|
# Верификация регистрирующихся по домену email: при включении
|
||||||
# `POST /auth/register` принимает только
|
# `POST /auth/register` принимает только email с доменом из
|
||||||
# email с доменом `registration_email_domain` — см.
|
# `registration_email_domains` (любым из списка) — см.
|
||||||
# `services/instance_settings.py`.
|
# `services/instance_settings.py`.
|
||||||
registration_email_domain_enabled: bool = False
|
registration_email_domain_enabled: bool = False
|
||||||
registration_email_domain: str | None = None
|
registration_email_domains: list[str] = Field(default_factory=list)
|
||||||
# Контактный адрес инстанса — подставляется в `Reply-To` исходящих писем
|
# Контактный адрес инстанса — подставляется в `Reply-To` исходящих писем
|
||||||
# (сами письма уходят от `no-reply@`, отвечать на них некуда без этого
|
# (сами письма уходят от `no-reply@`, отвечать на них некуда без этого
|
||||||
# адреса) — см. `services/instance_settings.py`, `services/email.py`.
|
# адреса) — см. `services/instance_settings.py`, `services/email.py`.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import ColumnElement, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.conference import Conference
|
from models.conference import Conference
|
||||||
@@ -56,25 +56,37 @@ class AdminConferenceRepository:
|
|||||||
|
|
||||||
|
|
||||||
class AdminUserRepository:
|
class AdminUserRepository:
|
||||||
"""Постраничный список пользователей с текстовым поиском по email/имени."""
|
"""Постраничный список пользователей с фильтром по статусу и текстовым поиском."""
|
||||||
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
|
|
||||||
async def list_paginated(
|
async def list_paginated(
|
||||||
self, *, q: str | None, limit: int, offset: int
|
self, *, status: str | None, q: str | None, limit: int, offset: int
|
||||||
) -> tuple[list[tuple[User, str | None]], int]:
|
) -> tuple[list[tuple[User, str | None]], int]:
|
||||||
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
|
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
|
||||||
|
|
||||||
`LEFT JOIN` на `teams` — имя команды нужно карточке профиля/таблице
|
`status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` —
|
||||||
админки, у пользователя без команды — `None`.
|
без фильтра (все пользователи). `q` ищет по email/имени пользователя
|
||||||
|
И по названию команды (`LEFT JOIN` на `teams` — нужен и для этого
|
||||||
|
поиска, и для колонки «Команда» в таблице админки; `LEFT`, а не
|
||||||
|
`INNER`, — пользователи без команды не должны пропадать из общей
|
||||||
|
выдачи, только не совпадать с поиском по названию команды).
|
||||||
"""
|
"""
|
||||||
filters = []
|
filters: list[ColumnElement[bool]] = []
|
||||||
|
if status == "active":
|
||||||
|
filters.append(User.is_blocked.is_(False))
|
||||||
|
elif status == "blocked":
|
||||||
|
filters.append(User.is_blocked.is_(True))
|
||||||
if q:
|
if q:
|
||||||
like = f"%{q}%"
|
like = f"%{q}%"
|
||||||
filters.append(or_(User.email.ilike(like), User.name_user.ilike(like)))
|
filters.append(
|
||||||
|
or_(User.email.ilike(like), User.name_user.ilike(like), Team.name.ilike(like))
|
||||||
|
)
|
||||||
|
|
||||||
count_stmt = select(func.count()).select_from(User)
|
count_stmt = (
|
||||||
|
select(func.count()).select_from(User).outerjoin(Team, User.team_id == Team.id)
|
||||||
|
)
|
||||||
items_stmt = (
|
items_stmt = (
|
||||||
select(User, Team.name)
|
select(User, Team.name)
|
||||||
.outerjoin(Team, User.team_id == Team.id)
|
.outerjoin(Team, User.team_id == Team.id)
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class SettingsOut(BaseModel):
|
|||||||
display_timezone: str
|
display_timezone: str
|
||||||
registration_team_choice: bool
|
registration_team_choice: bool
|
||||||
registration_email_domain_enabled: bool
|
registration_email_domain_enabled: bool
|
||||||
registration_email_domain: str | None = None
|
registration_email_domains: list[str] = Field(default_factory=list)
|
||||||
contact_email_enabled: bool
|
contact_email_enabled: bool
|
||||||
contact_email: str | None = None
|
contact_email: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -103,11 +103,11 @@ class RegistrationOptionsOut(BaseModel):
|
|||||||
|
|
||||||
`teams` отдаётся только при `team_choice_enabled=True` — иначе пустой
|
`teams` отдаётся только при `team_choice_enabled=True` — иначе пустой
|
||||||
список (справочник команд не раскрывается, пока выбор выключен).
|
список (справочник команд не раскрывается, пока выбор выключен).
|
||||||
`email_domain` — эталонный домен при включённой верификации регистрации
|
`email_domains` — эталонные домены при включённой верификации регистрации
|
||||||
по домену email (настройка инстанса `registration_email_domain`), иначе
|
по домену email (настройка инстанса `registration_email_domain`), иначе
|
||||||
`None`.
|
пустой список.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
team_choice_enabled: bool
|
team_choice_enabled: bool
|
||||||
teams: list[RegistrationTeamOptionOut]
|
teams: list[RegistrationTeamOptionOut]
|
||||||
email_domain: str | None = None
|
email_domains: list[str] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -106,9 +106,9 @@ class AuthService:
|
|||||||
существует — иначе `InvalidTeamSelectionError` (публичный
|
существует — иначе `InvalidTeamSelectionError` (публичный
|
||||||
эндпоинт, деталей не раскрываем). Если включена верификация домена
|
эндпоинт, деталей не раскрываем). Если включена верификация домена
|
||||||
email (`registration_email_domain_enabled`), домен `email` (часть
|
email (`registration_email_domain_enabled`), домен `email` (часть
|
||||||
после `@`, без учёта регистра) должен совпадать с эталонным —
|
после `@`, без учёта регистра) должен совпадать с одним из
|
||||||
иначе `InvalidEmailDomainError`. Обе проверки — до создания
|
эталонных доменов (`registration_email_domains`) — иначе
|
||||||
пользователя.
|
`InvalidEmailDomainError`. Обе проверки — до создания пользователя.
|
||||||
"""
|
"""
|
||||||
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:
|
||||||
@@ -118,7 +118,7 @@ class AuthService:
|
|||||||
|
|
||||||
if cfg.registration_email_domain_enabled:
|
if cfg.registration_email_domain_enabled:
|
||||||
email_domain = email.rsplit("@", 1)[-1].lower()
|
email_domain = email.rsplit("@", 1)[-1].lower()
|
||||||
if email_domain != cfg.registration_email_domain:
|
if email_domain not in cfg.registration_email_domains:
|
||||||
raise InvalidEmailDomainError(email)
|
raise InvalidEmailDomainError(email)
|
||||||
|
|
||||||
if team_id is not None:
|
if team_id is not None:
|
||||||
|
|||||||
@@ -63,7 +63,12 @@ _DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
|
|||||||
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
|
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
|
||||||
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
|
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
|
||||||
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
|
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
|
||||||
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None}
|
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domains": []}
|
||||||
|
"""Формат значения ключа `registration_email_domain` в БД. До версии с
|
||||||
|
несколькими доменами хранилась форма `{"enabled": bool, "domain": str|None}`
|
||||||
|
(один домен) — читающий код (`_extract_email_domains`) понимает обе формы
|
||||||
|
для обратной совместимости с уже развёрнутыми инстансами; при первом же
|
||||||
|
`update()` значение переписывается в новую форму (см. `update`)."""
|
||||||
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
|
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
|
||||||
|
|
||||||
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
|
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
|
||||||
@@ -95,7 +100,7 @@ class SettingsUpdateIn(BaseModel):
|
|||||||
display_timezone: str | None = None
|
display_timezone: str | None = None
|
||||||
registration_team_choice: bool | None = None
|
registration_team_choice: bool | None = None
|
||||||
registration_email_domain_enabled: bool | None = None
|
registration_email_domain_enabled: bool | None = None
|
||||||
registration_email_domain: str | None = None
|
registration_email_domains: list[str] | None = None
|
||||||
contact_email_enabled: bool | None = None
|
contact_email_enabled: bool | None = None
|
||||||
contact_email: str | None = None
|
contact_email: str | None = None
|
||||||
|
|
||||||
@@ -237,7 +242,20 @@ class InstanceSettingsService:
|
|||||||
rows = await self._load_rows()
|
rows = await self._load_rows()
|
||||||
cfg = _build_config(rows)
|
cfg = _build_config(rows)
|
||||||
|
|
||||||
if patch.ai_level is not None:
|
if patch.ai_level is not None and patch.ai_level != cfg.ai_level:
|
||||||
|
# Валидация только при фактической смене уровня (сравнение с уже
|
||||||
|
# сохранённым cfg.ai_level) — иначе фронт, отправляющий текущий
|
||||||
|
# ai_level вместе с любой другой правкой (см. `AdminSettingsTab`),
|
||||||
|
# блокировал бы сохранение несвязанных настроек на слабом железе,
|
||||||
|
# где текущий (давно и легитимно сохранённый) уровень недоступен
|
||||||
|
# по факту заново переоценённых требований (RAM/модели).
|
||||||
|
#
|
||||||
|
# Не ослабляем проверку и при отключённых transcriber.enabled/
|
||||||
|
# summarizer.enabled (когда уровень AI сейчас ни на что не
|
||||||
|
# влияет): если проверять по факту переключения — это осознанное
|
||||||
|
# намерение администратора сменить уровень, и молчаливое
|
||||||
|
# сохранение недоступного значения подставит администратора при
|
||||||
|
# последующем включении AI неработающей конфигурацией.
|
||||||
statuses = {status.level: status for status in detect_ai_levels(cfg)}
|
statuses = {status.level: status for status in detect_ai_levels(cfg)}
|
||||||
if not statuses[patch.ai_level].available:
|
if not statuses[patch.ai_level].available:
|
||||||
raise InvalidAiLevelError(
|
raise InvalidAiLevelError(
|
||||||
@@ -267,26 +285,28 @@ class InstanceSettingsService:
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
patch.registration_email_domain_enabled is not None
|
patch.registration_email_domain_enabled is not None
|
||||||
or patch.registration_email_domain is not None
|
or patch.registration_email_domains is not None
|
||||||
):
|
):
|
||||||
enabled = (
|
enabled = (
|
||||||
patch.registration_email_domain_enabled
|
patch.registration_email_domain_enabled
|
||||||
if patch.registration_email_domain_enabled is not None
|
if patch.registration_email_domain_enabled is not None
|
||||||
else cfg.registration_email_domain_enabled
|
else cfg.registration_email_domain_enabled
|
||||||
)
|
)
|
||||||
raw_domain = (
|
raw_domains = (
|
||||||
patch.registration_email_domain
|
patch.registration_email_domains
|
||||||
if patch.registration_email_domain is not None
|
if patch.registration_email_domains is not None
|
||||||
else cfg.registration_email_domain
|
else cfg.registration_email_domains
|
||||||
)
|
)
|
||||||
domain = _normalize_email_domain(raw_domain) if raw_domain else None
|
domains = _normalize_email_domains(raw_domains)
|
||||||
if enabled and domain is None:
|
if enabled and not domains:
|
||||||
raise InvalidEmailDomainError(
|
raise InvalidEmailDomainError(
|
||||||
"нельзя включить верификацию домена email без указания домена"
|
"нельзя включить верификацию домена email без указания хотя бы одного домена"
|
||||||
)
|
)
|
||||||
cfg.registration_email_domain_enabled = enabled
|
cfg.registration_email_domain_enabled = enabled
|
||||||
cfg.registration_email_domain = domain
|
cfg.registration_email_domains = domains
|
||||||
await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain})
|
await self._set(
|
||||||
|
_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domains": domains}
|
||||||
|
)
|
||||||
|
|
||||||
if patch.contact_email_enabled is not None or patch.contact_email is not None:
|
if patch.contact_email_enabled is not None or patch.contact_email is not None:
|
||||||
contact_enabled = (
|
contact_enabled = (
|
||||||
@@ -402,6 +422,32 @@ def _normalize_email_domain(domain: str) -> str:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_email_domains(domains: list[str]) -> list[str]:
|
||||||
|
"""Нормализовать список доменов: strip/lower/убрать «@» на каждом (см.
|
||||||
|
`_normalize_email_domain`), отбросить пустые строки, убрать дубликаты
|
||||||
|
(с сохранением порядка первого вхождения)."""
|
||||||
|
normalized: list[str] = []
|
||||||
|
for raw in domains:
|
||||||
|
if not raw.strip():
|
||||||
|
continue
|
||||||
|
domain = _normalize_email_domain(raw)
|
||||||
|
if domain not in normalized:
|
||||||
|
normalized.append(domain)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_email_domains(value: dict[str, Any]) -> list[str]:
|
||||||
|
"""Достать список доменов из значения ключа `registration_email_domain`,
|
||||||
|
понимая и текущую форму (`domains: [...]`), и форму до многодоменной
|
||||||
|
поддержки (`domain: str | None`, один домен) — на проде уже записано
|
||||||
|
именно старое значение, миграция БД для этого не нужна: следующий же
|
||||||
|
`update()` перепишет строку в новую форму (см. docstring `update`)."""
|
||||||
|
if "domains" in value:
|
||||||
|
return list(value["domains"])
|
||||||
|
legacy_domain = value.get("domain")
|
||||||
|
return [legacy_domain] if legacy_domain else []
|
||||||
|
|
||||||
|
|
||||||
def _normalize_contact_email(email: str) -> str:
|
def _normalize_contact_email(email: str) -> str:
|
||||||
"""Нормализовать контактный email (strip, lower) и провалидировать формат."""
|
"""Нормализовать контактный email (strip, lower) и провалидировать формат."""
|
||||||
normalized = email.strip().lower()
|
normalized = email.strip().lower()
|
||||||
@@ -436,9 +482,9 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
|
|||||||
registration_email_domain_enabled=rows.get(
|
registration_email_domain_enabled=rows.get(
|
||||||
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
|
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
|
||||||
).get("enabled", False),
|
).get("enabled", False),
|
||||||
registration_email_domain=rows.get(
|
registration_email_domains=_extract_email_domains(
|
||||||
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
|
rows.get(_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE)
|
||||||
).get("domain"),
|
),
|
||||||
contact_email_enabled=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get(
|
contact_email_enabled=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get(
|
||||||
"enabled", False
|
"enabled", False
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -381,6 +381,67 @@ async def test_list_users_returns_all(client: httpx.AsyncClient, db_session: Asy
|
|||||||
assert all("email_verified" in item for item in items)
|
assert all("email_verified" in item for item in items)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_users_filters_by_status(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""`status=active`/`blocked` фильтрует по `is_blocked`; без параметра — все."""
|
||||||
|
admin = await _make_user(db_session, role="admin")
|
||||||
|
active_user = await _make_user(db_session)
|
||||||
|
blocked_user = await _make_user(db_session)
|
||||||
|
blocked_user.is_blocked = True
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
active_response = await client.get(
|
||||||
|
"/api/v1/admin/users", params={"status": "active"}, headers=_auth_headers(admin)
|
||||||
|
)
|
||||||
|
assert active_response.status_code == 200, active_response.text
|
||||||
|
active_ids = [item["id"] for item in active_response.json()["items"]]
|
||||||
|
assert str(active_user.id) in active_ids
|
||||||
|
assert str(blocked_user.id) not in active_ids
|
||||||
|
|
||||||
|
blocked_response = await client.get(
|
||||||
|
"/api/v1/admin/users", params={"status": "blocked"}, headers=_auth_headers(admin)
|
||||||
|
)
|
||||||
|
assert blocked_response.status_code == 200, blocked_response.text
|
||||||
|
blocked_ids = [item["id"] for item in blocked_response.json()["items"]]
|
||||||
|
assert str(blocked_user.id) in blocked_ids
|
||||||
|
assert str(active_user.id) not in blocked_ids
|
||||||
|
|
||||||
|
all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
|
||||||
|
all_ids = [item["id"] for item in all_response.json()["items"]]
|
||||||
|
assert str(active_user.id) in all_ids
|
||||||
|
assert str(blocked_user.id) in all_ids
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_users_search_matches_team_name(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""`q` находит пользователей по (части) названия их команды; пользователи
|
||||||
|
без команды при этом не пропадают из общей (без поиска) выдачи."""
|
||||||
|
admin = await _make_user(db_session, role="admin")
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
team = Team(name=f"Rocket-{suffix} Squad")
|
||||||
|
db_session.add(team)
|
||||||
|
await db_session.flush()
|
||||||
|
teamed_user = await _make_user(db_session)
|
||||||
|
teamed_user.team_id = team.id
|
||||||
|
teamless_user = await _make_user(db_session)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/admin/users", params={"q": f"Rocket-{suffix}"}, headers=_auth_headers(admin)
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
ids = [item["id"] for item in response.json()["items"]]
|
||||||
|
assert str(teamed_user.id) in ids
|
||||||
|
assert str(teamless_user.id) not in ids
|
||||||
|
assert str(admin.id) not in ids
|
||||||
|
|
||||||
|
all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
|
||||||
|
all_ids = [item["id"] for item in all_response.json()["items"]]
|
||||||
|
assert str(teamless_user.id) in all_ids
|
||||||
|
|
||||||
|
|
||||||
async def test_patch_user_role_and_block(
|
async def test_patch_user_role_and_block(
|
||||||
client: httpx.AsyncClient, db_session: AsyncSession
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ async def test_registration_options_disabled_by_default(
|
|||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["team_choice_enabled"] is False
|
assert body["team_choice_enabled"] is False
|
||||||
assert body["teams"] == []
|
assert body["teams"] == []
|
||||||
assert body["email_domain"] is None
|
assert body["email_domains"] == []
|
||||||
|
|
||||||
|
|
||||||
async def test_registration_options_enabled_returns_teams_sorted_by_name(
|
async def test_registration_options_enabled_returns_teams_sorted_by_name(
|
||||||
@@ -343,29 +343,30 @@ async def test_register_with_unknown_team_id_returns_400(
|
|||||||
# --- Верификация регистрирующихся по домену email -------------------------------------
|
# --- Верификация регистрирующихся по домену email -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def test_registration_options_returns_email_domain_when_enabled(
|
async def test_registration_options_returns_email_domains_when_enabled(
|
||||||
client: httpx.AsyncClient, db_session: AsyncSession
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
) -> None:
|
) -> None:
|
||||||
"""При включённой верификации домена `registration-options` отдаёт эталонный домен."""
|
"""При включённой верификации домена `registration-options` отдаёт все эталонные домены."""
|
||||||
await InstanceSettingsService(db_session).update(
|
await InstanceSettingsService(db_session).update(
|
||||||
SettingsUpdateIn(
|
SettingsUpdateIn(
|
||||||
registration_email_domain_enabled=True, registration_email_domain="example.com"
|
registration_email_domain_enabled=True,
|
||||||
|
registration_email_domains=["example.com", "corp.example"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
response = await client.get("/api/v1/auth/registration-options")
|
response = await client.get("/api/v1/auth/registration-options")
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
assert response.json()["email_domain"] == "example.com"
|
assert response.json()["email_domains"] == ["example.com", "corp.example"]
|
||||||
|
|
||||||
|
|
||||||
async def test_registration_options_email_domain_null_when_disabled(
|
async def test_registration_options_email_domains_empty_when_disabled(
|
||||||
client: httpx.AsyncClient, db_session: AsyncSession
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Выключенная верификация домена — `email_domain` всегда `null`, даже если домен сохранён."""
|
"""Выключенная верификация домена — `email_domains` всегда пуст, даже если домены сохранены."""
|
||||||
await InstanceSettingsService(db_session).update(
|
await InstanceSettingsService(db_session).update(
|
||||||
SettingsUpdateIn(
|
SettingsUpdateIn(
|
||||||
registration_email_domain_enabled=True, registration_email_domain="example.com"
|
registration_email_domain_enabled=True, registration_email_domains=["example.com"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await InstanceSettingsService(db_session).update(
|
await InstanceSettingsService(db_session).update(
|
||||||
@@ -375,7 +376,7 @@ async def test_registration_options_email_domain_null_when_disabled(
|
|||||||
|
|
||||||
response = await client.get("/api/v1/auth/registration-options")
|
response = await client.get("/api/v1/auth/registration-options")
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
assert response.json()["email_domain"] is None
|
assert response.json()["email_domains"] == []
|
||||||
|
|
||||||
|
|
||||||
async def test_register_with_foreign_domain_when_verification_enabled_returns_400(
|
async def test_register_with_foreign_domain_when_verification_enabled_returns_400(
|
||||||
@@ -383,7 +384,8 @@ async def test_register_with_foreign_domain_when_verification_enabled_returns_40
|
|||||||
) -> None:
|
) -> None:
|
||||||
await InstanceSettingsService(db_session).update(
|
await InstanceSettingsService(db_session).update(
|
||||||
SettingsUpdateIn(
|
SettingsUpdateIn(
|
||||||
registration_email_domain_enabled=True, registration_email_domain="example.com"
|
registration_email_domain_enabled=True,
|
||||||
|
registration_email_domains=["example.com", "corp.example"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
@@ -405,7 +407,7 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
|
|||||||
) -> None:
|
) -> None:
|
||||||
await InstanceSettingsService(db_session).update(
|
await InstanceSettingsService(db_session).update(
|
||||||
SettingsUpdateIn(
|
SettingsUpdateIn(
|
||||||
registration_email_domain_enabled=True, registration_email_domain="example.com"
|
registration_email_domain_enabled=True, registration_email_domains=["example.com"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
@@ -421,6 +423,29 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
|
|||||||
assert response.status_code == 201, response.text
|
assert response.status_code == 201, response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_with_second_domain_in_list_succeeds(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
|
||||||
|
) -> None:
|
||||||
|
"""Email подходит, если совпадает с ЛЮБЫМ доменом из списка — не только с первым."""
|
||||||
|
await InstanceSettingsService(db_session).update(
|
||||||
|
SettingsUpdateIn(
|
||||||
|
registration_email_domain_enabled=True,
|
||||||
|
registration_email_domains=["example.com", "corp.example"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "dave@corp.example",
|
||||||
|
"name_user": "Dave",
|
||||||
|
"password": "supersecret1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
|
||||||
|
|
||||||
async def test_register_any_domain_allowed_when_verification_disabled(
|
async def test_register_any_domain_allowed_when_verification_disabled(
|
||||||
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
|
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
|||||||
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
|
||||||
assert cfg.registration_email_domain_enabled is False
|
assert cfg.registration_email_domain_enabled is False
|
||||||
assert cfg.registration_email_domain is None
|
assert cfg.registration_email_domains == []
|
||||||
assert cfg.contact_email_enabled is False
|
assert cfg.contact_email_enabled is False
|
||||||
assert cfg.contact_email is None
|
assert cfg.contact_email is None
|
||||||
|
|
||||||
@@ -326,7 +326,7 @@ async def test_registration_team_choice_toggle(
|
|||||||
async def test_registration_email_domain_enable_without_domain_rejected(
|
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:
|
||||||
"""Включение верификации без домена (ни в патче, ни ранее сохранённого) → 400."""
|
"""Включение верификации без доменов (ни в патче, ни ранее сохранённых) → 400."""
|
||||||
service = InstanceSettingsService(db_session)
|
service = InstanceSettingsService(db_session)
|
||||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
|
||||||
@@ -335,7 +335,24 @@ async def test_registration_email_domain_enable_without_domain_rejected(
|
|||||||
|
|
||||||
cfg = await service.get()
|
cfg = await service.get()
|
||||||
assert cfg.registration_email_domain_enabled is False
|
assert cfg.registration_email_domain_enabled is False
|
||||||
assert cfg.registration_email_domain is None
|
assert cfg.registration_email_domains == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_registration_email_domain_enable_with_empty_list_rejected(
|
||||||
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
|
) -> None:
|
||||||
|
"""Пустой список доменов при включении верификации — та же ошибка, что и
|
||||||
|
отсутствие поля (не молчаливое отключение проверки)."""
|
||||||
|
service = InstanceSettingsService(db_session)
|
||||||
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidEmailDomainError):
|
||||||
|
await service.update(
|
||||||
|
SettingsUpdateIn(registration_email_domain_enabled=True, registration_email_domains=[])
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = await service.get()
|
||||||
|
assert cfg.registration_email_domain_enabled is False
|
||||||
|
|
||||||
|
|
||||||
async def test_registration_email_domain_rejects_invalid_pattern(
|
async def test_registration_email_domain_rejects_invalid_pattern(
|
||||||
@@ -345,48 +362,106 @@ async def test_registration_email_domain_rejects_invalid_pattern(
|
|||||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
|
||||||
with pytest.raises(InvalidEmailDomainError):
|
with pytest.raises(InvalidEmailDomainError):
|
||||||
await service.update(SettingsUpdateIn(registration_email_domain="not a domain"))
|
await service.update(SettingsUpdateIn(registration_email_domains=["not a domain"]))
|
||||||
|
|
||||||
cfg = await service.get()
|
cfg = await service.get()
|
||||||
assert cfg.registration_email_domain is None
|
assert cfg.registration_email_domains == []
|
||||||
|
|
||||||
|
|
||||||
async def test_registration_email_domain_normalizes_input(
|
async def test_registration_email_domain_normalizes_input(
|
||||||
db_session: AsyncSession, clean_instance_settings: None
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower)."""
|
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower); пустые строки
|
||||||
|
отбрасываются, дубликаты схлопываются."""
|
||||||
service = InstanceSettingsService(db_session)
|
service = InstanceSettingsService(db_session)
|
||||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
|
||||||
cfg = await service.update(
|
cfg = await service.update(
|
||||||
SettingsUpdateIn(
|
SettingsUpdateIn(
|
||||||
registration_email_domain_enabled=True, registration_email_domain="@Corp.RU "
|
registration_email_domain_enabled=True,
|
||||||
|
registration_email_domains=["@Corp.RU ", "", "corp.ru", "Acme.IO"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert cfg.registration_email_domain_enabled is True
|
assert cfg.registration_email_domain_enabled is True
|
||||||
assert cfg.registration_email_domain == "corp.ru"
|
assert cfg.registration_email_domains == ["corp.ru", "acme.io"]
|
||||||
|
|
||||||
reloaded = await service.get()
|
reloaded = await service.get()
|
||||||
assert reloaded.registration_email_domain == "corp.ru"
|
assert reloaded.registration_email_domains == ["corp.ru", "acme.io"]
|
||||||
|
|
||||||
|
|
||||||
async def test_registration_email_domain_can_be_disabled_keeping_stored_domain(
|
async def test_registration_email_domain_can_be_disabled_keeping_stored_domains(
|
||||||
db_session: AsyncSession, clean_instance_settings: None
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Выключение верификации без передачи домена не требует домена и не роняет валидацию."""
|
"""Выключение верификации без передачи доменов не требует их и не роняет валидацию."""
|
||||||
service = InstanceSettingsService(db_session)
|
service = InstanceSettingsService(db_session)
|
||||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
await service.update(
|
await service.update(
|
||||||
SettingsUpdateIn(
|
SettingsUpdateIn(
|
||||||
registration_email_domain_enabled=True, registration_email_domain="acme.io"
|
registration_email_domain_enabled=True, registration_email_domains=["acme.io"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg = await service.update(SettingsUpdateIn(registration_email_domain_enabled=False))
|
cfg = await service.update(SettingsUpdateIn(registration_email_domain_enabled=False))
|
||||||
|
|
||||||
assert cfg.registration_email_domain_enabled is False
|
assert cfg.registration_email_domain_enabled is False
|
||||||
assert cfg.registration_email_domain == "acme.io"
|
assert cfg.registration_email_domains == ["acme.io"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_registration_email_domain_reads_legacy_single_domain_shape(
|
||||||
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
|
) -> None:
|
||||||
|
"""До многодоменной поддержки в БД хранилась форма `{"domain": str|None}` (один
|
||||||
|
домен, без миграции на проде) — чтение должно разворачивать её в список из
|
||||||
|
одного элемента (см. `_extract_email_domains`)."""
|
||||||
|
service = InstanceSettingsService(db_session)
|
||||||
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
stmt = (
|
||||||
|
pg_insert(InstanceSetting)
|
||||||
|
.values(
|
||||||
|
key="registration_email_domain",
|
||||||
|
value={"enabled": True, "domain": "legacy.example"},
|
||||||
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=["key"],
|
||||||
|
set_={"value": {"enabled": True, "domain": "legacy.example"}},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.execute(stmt)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
cfg = await service.get()
|
||||||
|
|
||||||
|
assert cfg.registration_email_domain_enabled is True
|
||||||
|
assert cfg.registration_email_domains == ["legacy.example"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_registration_email_domain_rewrites_legacy_shape_on_update(
|
||||||
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
|
) -> None:
|
||||||
|
"""Первое же сохранение после чтения старой формы переписывает строку в
|
||||||
|
новую (`domains: [...]`), а не оставляет legacy `domain` рядом."""
|
||||||
|
service = InstanceSettingsService(db_session)
|
||||||
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
stmt = (
|
||||||
|
pg_insert(InstanceSetting)
|
||||||
|
.values(
|
||||||
|
key="registration_email_domain",
|
||||||
|
value={"enabled": True, "domain": "legacy.example"},
|
||||||
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=["key"],
|
||||||
|
set_={"value": {"enabled": True, "domain": "legacy.example"}},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.execute(stmt)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
await service.update(SettingsUpdateIn(registration_email_domains=["new.example"]))
|
||||||
|
|
||||||
|
row = await db_session.get(InstanceSetting, "registration_email_domain")
|
||||||
|
assert row is not None
|
||||||
|
assert row.value == {"enabled": True, "domains": ["new.example"]}
|
||||||
|
|
||||||
|
|
||||||
async def test_contact_email_enable_without_email_rejected(
|
async def test_contact_email_enable_without_email_rejected(
|
||||||
@@ -478,6 +553,52 @@ async def test_update_rejects_unavailable_ai_level(
|
|||||||
assert cfg.ai_level == "min"
|
assert cfg.ai_level == "min"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_unrelated_setting_passes_when_current_ai_level_unavailable(
|
||||||
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
|
) -> None:
|
||||||
|
"""Баг: фронт всегда отправляет текущий `ai_level` вместе с любой другой
|
||||||
|
правкой (см. `AdminSettingsTab.handleSave`) — если уже сохранённый
|
||||||
|
уровень стал недоступен (слабое железо/модель не докачана), это не
|
||||||
|
должно блокировать сохранение несвязанной настройки. Валидация нужна
|
||||||
|
только при фактической смене уровня (см. docstring `update`)."""
|
||||||
|
service = InstanceSettingsService(db_session)
|
||||||
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
stmt = (
|
||||||
|
pg_insert(InstanceSetting)
|
||||||
|
.values(key="ai_level", value={"level": "medium"})
|
||||||
|
.on_conflict_do_update(index_elements=["key"], set_={"value": {"level": "medium"}})
|
||||||
|
)
|
||||||
|
await db_session.execute(stmt)
|
||||||
|
await db_session.commit()
|
||||||
|
assert not detect_ai_levels(await service.get())[1].available # "medium" недоступен
|
||||||
|
|
||||||
|
# ai_level в патче совпадает с уже сохранённым — не должно проверяться.
|
||||||
|
cfg = await service.update(
|
||||||
|
SettingsUpdateIn(ai_level="medium", registration_team_choice=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cfg.ai_level == "medium"
|
||||||
|
assert cfg.registration_team_choice is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_still_rejects_switching_to_unavailable_ai_level(
|
||||||
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
|
) -> None:
|
||||||
|
"""Фактическая попытка ПЕРЕКЛЮЧИТЬ уровень на недоступный по-прежнему
|
||||||
|
отклоняется — даже если AI (транскрибация/суммаризация) выключен: иначе
|
||||||
|
администратор молча сохранит нерабочую конфигурацию, которая проявится
|
||||||
|
только при последующем включении AI."""
|
||||||
|
service = InstanceSettingsService(db_session)
|
||||||
|
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||||
|
await service.update(SettingsUpdateIn(transcription_enabled=False))
|
||||||
|
|
||||||
|
with pytest.raises(InvalidAiLevelError):
|
||||||
|
await service.update(SettingsUpdateIn(ai_level="medium"))
|
||||||
|
|
||||||
|
cfg = await service.get()
|
||||||
|
assert cfg.ai_level == "min"
|
||||||
|
|
||||||
|
|
||||||
async def test_update_rejects_invalid_timezone(
|
async def test_update_rejects_invalid_timezone(
|
||||||
db_session: AsyncSession, clean_instance_settings: None
|
db_session: AsyncSession, clean_instance_settings: None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -82,7 +82,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.6}
|
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.8}
|
||||||
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
|
||||||
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
# только backend/), поэтому plugins.yaml монтируется отдельно.
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ export interface SettingsOut {
|
|||||||
registration_team_choice: boolean
|
registration_team_choice: boolean
|
||||||
/** Включена ли верификация регистрации по домену корпоративной почты. */
|
/** Включена ли верификация регистрации по домену корпоративной почты. */
|
||||||
registration_email_domain_enabled: boolean
|
registration_email_domain_enabled: boolean
|
||||||
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
|
/** Эталонные домены для верификации (напр. `['company.ru']`) — email подходит, если
|
||||||
registration_email_domain: string | null
|
* совпадает с любым из списка; пуст, если верификация выключена. */
|
||||||
|
registration_email_domains: string[]
|
||||||
/** Включён ли контактный адрес инстанса (подставляется в `Reply-To` исходящих писем). */
|
/** Включён ли контактный адрес инстанса (подставляется в `Reply-To` исходящих писем). */
|
||||||
contact_email_enabled: boolean
|
contact_email_enabled: boolean
|
||||||
/** Контактный адрес — `null`, если не задан/выключен. */
|
/** Контактный адрес — `null`, если не задан/выключен. */
|
||||||
@@ -49,9 +50,9 @@ export interface SettingsUpdateIn {
|
|||||||
summary_recipients?: SummaryRecipientsMode
|
summary_recipients?: SummaryRecipientsMode
|
||||||
display_timezone?: string
|
display_timezone?: string
|
||||||
registration_team_choice?: boolean
|
registration_team_choice?: boolean
|
||||||
/** Включение без домена или невалидный домен — backend отвечает 400. */
|
/** Включение с пустым списком или невалидным доменом — backend отвечает 400. */
|
||||||
registration_email_domain_enabled?: boolean
|
registration_email_domain_enabled?: boolean
|
||||||
registration_email_domain?: string | null
|
registration_email_domains?: string[]
|
||||||
/** Включение без email или невалидный email — backend отвечает 400. */
|
/** Включение без email или невалидный email — backend отвечает 400. */
|
||||||
contact_email_enabled?: boolean
|
contact_email_enabled?: boolean
|
||||||
contact_email?: string | null
|
contact_email?: string | null
|
||||||
@@ -136,6 +137,8 @@ export type AdminUserDetailOut = AdminUserOut
|
|||||||
|
|
||||||
/** Параметры выборки списка пользователей. */
|
/** Параметры выборки списка пользователей. */
|
||||||
export interface AdminUserQuery {
|
export interface AdminUserQuery {
|
||||||
|
/** Фильтр по блокировке — без параметра отдаются все пользователи. */
|
||||||
|
status?: 'active' | 'blocked'
|
||||||
q?: string
|
q?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
@@ -220,7 +223,7 @@ export async function sendConferenceInvitations(id: string, emails?: string[]):
|
|||||||
|
|
||||||
/** Список пользователей для админки — с поиском и пагинацией. */
|
/** Список пользователей для админки — с поиском и пагинацией. */
|
||||||
export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> {
|
export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> {
|
||||||
const qs = toQueryString({ q: query.q, limit: query.limit, offset: query.offset })
|
const qs = toQueryString({ status: query.status, q: query.q, limit: query.limit, offset: query.offset })
|
||||||
return apiRequest<PagedResult<AdminUserOut>>(`/admin/users${qs}`)
|
return apiRequest<PagedResult<AdminUserOut>>(`/admin/users${qs}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export interface RegistrationTeamOption {
|
|||||||
export interface RegistrationOptions {
|
export interface RegistrationOptions {
|
||||||
team_choice_enabled: boolean
|
team_choice_enabled: boolean
|
||||||
teams: RegistrationTeamOption[]
|
teams: RegistrationTeamOption[]
|
||||||
/** Эталонный домен почты при включённой верификации, иначе `null`. */
|
/** Эталонные домены почты при включённой верификации (email подходит под любой), иначе пуст. */
|
||||||
email_domain: string | null
|
email_domains: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { AlertTriangle } from 'lucide-react'
|
import { AlertTriangle, X } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getAdminSettings,
|
getAdminSettings,
|
||||||
sendTestEmail,
|
sendTestEmail,
|
||||||
@@ -60,7 +60,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
const [timezone, setTimezone] = useState(data.display_timezone)
|
const [timezone, setTimezone] = useState(data.display_timezone)
|
||||||
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
|
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
|
||||||
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
|
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
|
||||||
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
|
const [emailDomains, setEmailDomains] = useState(data.registration_email_domains)
|
||||||
|
const [newDomainInput, setNewDomainInput] = useState('')
|
||||||
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
|
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
|
||||||
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
|
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
|
||||||
const [testEmailTo, setTestEmailTo] = useState('')
|
const [testEmailTo, setTestEmailTo] = useState('')
|
||||||
@@ -94,19 +95,48 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function addDomain() {
|
||||||
|
const domain = newDomainInput.trim().toLowerCase().replace(/^@/, '')
|
||||||
|
if (!domain || emailDomains.includes(domain)) {
|
||||||
|
setNewDomainInput('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEmailDomains([...emailDomains, domain])
|
||||||
|
setNewDomainInput('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDomain(domain: string) {
|
||||||
|
setEmailDomains(emailDomains.filter((d) => d !== domain))
|
||||||
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
mutation.mutate({
|
// Отправляем только реально изменённые поля (`SettingsUpdateIn` — набор
|
||||||
chat_enabled: chatEnabled,
|
// опциональных полей именно для этого): иначе, например, ai_level
|
||||||
transcription_enabled: aiEnabled,
|
// уходил бы в PUT нетронутым при каждом сохранении и на слабом железе
|
||||||
ai_level: aiLevel,
|
// валился бы в 400, блокируя правку вообще любой другой настройки.
|
||||||
summary_recipients: recipients,
|
const payload: SettingsUpdateIn = {}
|
||||||
display_timezone: timezone.trim(),
|
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
|
||||||
registration_team_choice: teamChoiceEnabled,
|
if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
|
||||||
registration_email_domain_enabled: domainVerificationEnabled,
|
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
|
||||||
registration_email_domain: emailDomain.trim() || null,
|
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
|
||||||
contact_email_enabled: contactEmailEnabled,
|
if (timezone.trim() !== data.display_timezone) payload.display_timezone = timezone.trim()
|
||||||
contact_email: contactEmail.trim() || null,
|
if (teamChoiceEnabled !== data.registration_team_choice) {
|
||||||
})
|
payload.registration_team_choice = teamChoiceEnabled
|
||||||
|
}
|
||||||
|
if (domainVerificationEnabled !== data.registration_email_domain_enabled) {
|
||||||
|
payload.registration_email_domain_enabled = domainVerificationEnabled
|
||||||
|
}
|
||||||
|
if (JSON.stringify(emailDomains) !== JSON.stringify(data.registration_email_domains)) {
|
||||||
|
payload.registration_email_domains = emailDomains
|
||||||
|
}
|
||||||
|
if (contactEmailEnabled !== data.contact_email_enabled) {
|
||||||
|
payload.contact_email_enabled = contactEmailEnabled
|
||||||
|
}
|
||||||
|
const trimmedContactEmail = contactEmail.trim() || null
|
||||||
|
if (trimmedContactEmail !== (data.contact_email ?? null)) {
|
||||||
|
payload.contact_email = trimmedContactEmail
|
||||||
|
}
|
||||||
|
mutation.mutate(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -229,19 +259,53 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
|||||||
|
|
||||||
<section className="settings-card">
|
<section className="settings-card">
|
||||||
<h2>Эталон mail-домена</h2>
|
<h2>Эталон mail-домена</h2>
|
||||||
<p className="desc">Домен, с которым сверяется email при регистрации, если включена верификация в «Модулях».</p>
|
<p className="desc">Домены, с любым из которых сверяется email при регистрации, если включена верификация в «Модулях».</p>
|
||||||
|
|
||||||
<div className="settings-card-body settings-card-body--center">
|
<div className="settings-card-body">
|
||||||
|
{emailDomains.length > 0 && (
|
||||||
|
<div className="participants-chips">
|
||||||
|
{emailDomains.map((domain) => (
|
||||||
|
<span className="participant-chip" key={domain}>
|
||||||
|
{domain}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="participant-chip-remove"
|
||||||
|
aria-label={`Убрать домен: ${domain}`}
|
||||||
|
onClick={() => removeDomain(domain)}
|
||||||
|
disabled={!domainVerificationEnabled}
|
||||||
|
>
|
||||||
|
<X style={{ width: 12, height: 12 }} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
<label htmlFor="settings-email-domain">Домен корпоративной почты</label>
|
<label htmlFor="settings-email-domain">Домен корпоративной почты</label>
|
||||||
<input
|
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||||||
id="settings-email-domain"
|
<input
|
||||||
type="text"
|
id="settings-email-domain"
|
||||||
placeholder="company.ru"
|
type="text"
|
||||||
value={emailDomain}
|
placeholder="company.ru"
|
||||||
disabled={!domainVerificationEnabled}
|
value={newDomainInput}
|
||||||
onChange={(e) => setEmailDomain(e.target.value)}
|
disabled={!domainVerificationEnabled}
|
||||||
/>
|
onChange={(e) => setNewDomainInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
addDomain()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={!domainVerificationEnabled || !newDomainInput.trim()}
|
||||||
|
onClick={addDomain}
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ const PAGE_SIZE = 10
|
|||||||
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
|
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
|
||||||
const TEAMS_LIMIT = 200
|
const TEAMS_LIMIT = 200
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { value: 'active' | 'blocked' | 'all'; label: string }[] = [
|
||||||
|
{ value: 'active', label: 'Активные' },
|
||||||
|
{ value: 'blocked', label: 'Заблокированные' },
|
||||||
|
{ value: 'all', label: 'Все' },
|
||||||
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Вкладка «Пользователи» админки — список с поиском, пагинацией, сменой
|
* Вкладка «Пользователи» админки — список с поиском, пагинацией, сменой
|
||||||
* роли, блокировкой и командой (design/mockups/admin.html,
|
* роли, блокировкой и командой (design/mockups/admin.html,
|
||||||
@@ -28,6 +34,7 @@ const TEAMS_LIMIT = 200
|
|||||||
*/
|
*/
|
||||||
export function AdminUsersTab() {
|
export function AdminUsersTab() {
|
||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'active' | 'blocked' | 'all'>('all')
|
||||||
const [searchInput, setSearchInput] = useState('')
|
const [searchInput, setSearchInput] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [offset, setOffset] = useState(0)
|
const [offset, setOffset] = useState(0)
|
||||||
@@ -45,8 +52,14 @@ export function AdminUsersTab() {
|
|||||||
}, [searchInput])
|
}, [searchInput])
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'users', search, offset],
|
queryKey: ['admin', 'users', statusFilter, search, offset],
|
||||||
queryFn: () => listAdminUsers({ q: search || undefined, limit: PAGE_SIZE, offset }),
|
queryFn: () =>
|
||||||
|
listAdminUsers({
|
||||||
|
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||||
|
q: search || undefined,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: teamsData } = useQuery({
|
const { data: teamsData } = useQuery({
|
||||||
@@ -89,13 +102,29 @@ export function AdminUsersTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="section-tabs-inline">
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
type="button"
|
||||||
|
className={`chip${statusFilter === f.value ? ' is-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter(f.value)
|
||||||
|
setOffset(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-row">
|
<div className="toolbar-row">
|
||||||
<div className="toolbar-left">
|
<div className="toolbar-left">
|
||||||
<div className="search-wrap">
|
<div className="search-wrap">
|
||||||
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Поиск по имени или email…"
|
placeholder="Поиск по имени, email или команде…"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -41,15 +41,12 @@ export function AuthLayout({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="hint-msg" style={{ position: 'relative', zIndex: 1 }}>
|
|
||||||
self-hosted
|
|
||||||
</p>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Копирайт с годом и версией инстанса — общий `AppFooter` (он же на всех
|
{/* Копирайт с годом и версией инстанса — общий `AppFooter` (он же на всех
|
||||||
остальных страницах). Из брендовой панели строку «© 2026 VidConf ·
|
остальных страницах). Строку «© 2026 VidConf · self-hosted» из
|
||||||
self-hosted» при этом ужали до «self-hosted»: год там был
|
брендовой панели при этом убрали совсем (решение оператора): год в ней
|
||||||
захардкожен, а два копирайта на одном экране — дубль. */}
|
был захардкожен, а копирайт теперь живёт в футере. */}
|
||||||
<main className="form-panel">
|
<main className="form-panel">
|
||||||
{children}
|
{children}
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
|
|||||||
@@ -1,23 +1,51 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
|
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
|
||||||
import { useToast } from '@/components/ui/ToastProvider'
|
import { useToast } from '@/components/ui/ToastProvider'
|
||||||
|
import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||||||
|
import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||||
|
|
||||||
interface DeviceSettingsDialogProps {
|
interface DeviceSettingsDialogProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`) — ниже него панель рендерится шторкой снизу вместо модалки. */
|
||||||
|
const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)'
|
||||||
|
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */
|
||||||
|
const SHEET_DISMISS_THRESHOLD_PX = 80
|
||||||
|
|
||||||
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
|
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
|
||||||
function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string {
|
function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string {
|
||||||
return device.label || `${fallback} ${index + 1}`
|
return device.label || `${fallback} ${index + 1}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Живое отслеживание мобильной ширины — та же схема, что системная тема в `useTheme.ts` (matchMedia + change-листенер). */
|
||||||
|
function useIsCompactViewport(): boolean {
|
||||||
|
const [isCompact, setIsCompact] = useState(() => window.matchMedia(COMPACT_VIEWPORT_QUERY).matches)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia(COMPACT_VIEWPORT_QUERY)
|
||||||
|
const handleChange = (event: MediaQueryListEvent) => setIsCompact(event.matches)
|
||||||
|
media.addEventListener('change', handleChange)
|
||||||
|
return () => media.removeEventListener('change', handleChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return isCompact
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Диалог «Настройки устройств» — два селекта
|
* Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`:
|
||||||
* на хуках `@livekit/components-react`: список устройств и переключение —
|
* список устройств и переключение целиком в `useMediaDeviceSelect` (сама
|
||||||
* целиком в `useMediaDeviceSelect` (сама подписана на
|
* подписана на `RoomEvent.MediaDevicesChanged`), персист выбора микрофона/камеры —
|
||||||
* `RoomEvent.MediaDevicesChanged`), персист выбора — в `usePersistentUserChoices`
|
* в `usePersistentUserChoices` (localStorage, читается заново при следующем
|
||||||
* (localStorage, читается заново при следующем входе в комнату — см.
|
* входе в комнату — см. `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
|
||||||
* `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
|
* Аудиовыход персистится отдельно (`lib/audioOutputDevice.ts`) — `LocalUserChoices`
|
||||||
|
* LiveKit про него не знает, а `RoomOptions.audioOutput` читает сохранённый
|
||||||
|
* `deviceId` при следующем подключении.
|
||||||
|
*
|
||||||
|
* На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо
|
||||||
|
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку.
|
||||||
|
* Десктоп не меняется.
|
||||||
*
|
*
|
||||||
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
|
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
|
||||||
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
|
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
|
||||||
@@ -29,6 +57,15 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
|||||||
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
|
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
|
||||||
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
|
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
|
||||||
const camera = useMediaDeviceSelect({ kind: 'videoinput' })
|
const camera = useMediaDeviceSelect({ kind: 'videoinput' })
|
||||||
|
const speaker = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||||
|
const speakerSelectable = isAudioOutputSelectable()
|
||||||
|
const isCompact = useIsCompactViewport()
|
||||||
|
|
||||||
|
useModalDismiss(onClose)
|
||||||
|
|
||||||
|
const dragStartYRef = useRef<number | null>(null)
|
||||||
|
const [dragOffset, setDragOffset] = useState(0)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
async function handleMicChange(deviceId: string) {
|
async function handleMicChange(deviceId: string) {
|
||||||
try {
|
try {
|
||||||
@@ -49,15 +86,62 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSpeakerChange(deviceId: string) {
|
||||||
|
try {
|
||||||
|
await speaker.setActiveMediaDevice(deviceId)
|
||||||
|
saveAudioOutputDeviceId(deviceId)
|
||||||
|
} catch {
|
||||||
|
toast.show('Не удалось переключить вывод звука — устройство занято или отключено', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHandleTouchStart(e: React.TouchEvent<HTMLDivElement>) {
|
||||||
|
dragStartYRef.current = e.touches[0].clientY
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHandleTouchMove(e: React.TouchEvent<HTMLDivElement>) {
|
||||||
|
if (dragStartYRef.current === null) return
|
||||||
|
const delta = e.touches[0].clientY - dragStartYRef.current
|
||||||
|
if (delta > 0) setDragOffset(delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHandleTouchEnd() {
|
||||||
|
if (dragOffset > SHEET_DISMISS_THRESHOLD_PX) {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dragStartYRef.current = null
|
||||||
|
setIsDragging(false)
|
||||||
|
setDragOffset(0)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="room-modal-overlay"
|
className={isCompact ? 'room-sheet-overlay' : 'room-modal-overlay'}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="device-settings-title"
|
aria-labelledby="device-settings-title"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div className="room-modal-panel" onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
|
className={isCompact ? 'room-sheet-panel' : 'room-modal-panel'}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={
|
||||||
|
isCompact && dragOffset
|
||||||
|
? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isCompact && (
|
||||||
|
<div
|
||||||
|
className="room-sheet-handle"
|
||||||
|
onTouchStart={handleHandleTouchStart}
|
||||||
|
onTouchMove={handleHandleTouchMove}
|
||||||
|
onTouchEnd={handleHandleTouchEnd}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="room-modal-head">
|
<div className="room-modal-head">
|
||||||
<h2 id="device-settings-title">Настройки устройств</h2>
|
<h2 id="device-settings-title">Настройки устройств</h2>
|
||||||
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
|
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
|
||||||
@@ -104,6 +188,34 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{speakerSelectable ? (
|
||||||
|
<div className="room-field">
|
||||||
|
<label htmlFor="device-settings-speaker">Динамики</label>
|
||||||
|
<select
|
||||||
|
id="device-settings-speaker"
|
||||||
|
value={speaker.activeDeviceId}
|
||||||
|
onChange={(e) => void handleSpeakerChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Определяется…
|
||||||
|
</option>
|
||||||
|
{speaker.devices.map((device, index) => (
|
||||||
|
<option key={device.deviceId} value={device.deviceId}>
|
||||||
|
{deviceLabel(device, index, 'Динамик')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="room-field">
|
||||||
|
<span className="room-field-label">Динамики</span>
|
||||||
|
<p className="room-field-hint">
|
||||||
|
Вывод звука (динамик, наушники, Bluetooth) переключается средствами системы — этот
|
||||||
|
браузер не позволяет управлять им со страницы.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
33
frontend/src/lib/audioOutputDevice.ts
Normal file
33
frontend/src/lib/audioOutputDevice.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const STORAGE_KEY = 'vidconf-audio-output-device'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поддержка выбора устройства аудиовывода — определяем по наличию
|
||||||
|
* `setSinkId` в рантайме, НЕ по User-Agent: в iOS Safari метода нет вообще
|
||||||
|
* (маршрутизация звука там целиком на стороне ОС), в Android Chrome есть.
|
||||||
|
*/
|
||||||
|
export function isAudioOutputSelectable(): boolean {
|
||||||
|
return typeof HTMLMediaElement !== 'undefined' && 'setSinkId' in HTMLMediaElement.prototype
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Персист выбранного устройства вывода — отдельно от `usePersistentUserChoices`
|
||||||
|
* LiveKit: их `LocalUserChoices` знает только про вход (микрофон/камера),
|
||||||
|
* поля для аудиовыхода там нет.
|
||||||
|
*/
|
||||||
|
export function loadAudioOutputDeviceId(): string {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) ?? ''
|
||||||
|
} catch {
|
||||||
|
// localStorage недоступен (приватный режим/политики браузера) — без
|
||||||
|
// сохранённого выбора, устройство по умолчанию.
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAudioOutputDeviceId(deviceId: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, deviceId)
|
||||||
|
} catch {
|
||||||
|
// Сохранение недоступно — выбор продержится до конца сессии в комнате.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,19 +6,21 @@ 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'
|
||||||
|
|
||||||
/** Текст предупреждения о несовпадении домена почты с эталонным. */
|
/** Текст предупреждения о несовпадении домена почты с эталонными. */
|
||||||
function domainMismatchMessage(domain: string): string {
|
function domainMismatchMessage(domains: string[]): string {
|
||||||
return `Укажите рабочую почту — регистрация доступна только для домена @${domain}`
|
const list = domains.map((domain) => `@${domain}`).join(' или ')
|
||||||
|
return `Укажите рабочую почту — регистрация доступна только для домена ${list}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экран регистрации (см. design/mockups/auth.html, блок «регистрация»).
|
* Экран регистрации (см. design/mockups/auth.html, блок «регистрация»).
|
||||||
* Поле «Команда» показывается только если выбор команды включён в
|
* Поле «Команда» показывается только если выбор команды включён в
|
||||||
* настройках инстанса (`GET /auth/registration-options`, публичный эндпоинт).
|
* настройках инстанса (`GET /auth/registration-options`, публичный эндпоинт).
|
||||||
* Поле «Рабочая почта» дополнительно сверяется с эталонным доменом
|
* Поле «Рабочая почта» дополнительно сверяется с эталонными доменами
|
||||||
* (`registration-options.email_domain`), если в админке включена
|
* (`registration-options.email_domains` — подходит совпадение с ЛЮБЫМ),
|
||||||
* верификация по домену — проверка идёт по blur и при сабмите, backend
|
* если в админке включена верификация по домену — проверка идёт по blur и
|
||||||
* при включённой верификации и чужом домене отвечает 400 `invalid_email_domain`.
|
* при сабмите, backend при включённой верификации и чужом домене отвечает
|
||||||
|
* 400 `invalid_email_domain`.
|
||||||
* После успешной регистрации показывает состояние «подтвердите почту»
|
* После успешной регистрации показывает состояние «подтвердите почту»
|
||||||
* (письмо со ссылкой backend в dev-режиме печатает в консоль).
|
* (письмо со ссылкой backend в dev-режиме печатает в консоль).
|
||||||
*/
|
*/
|
||||||
@@ -38,16 +40,16 @@ 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 expectedEmailDomain = registrationOptions?.email_domain ?? null
|
const expectedEmailDomains = registrationOptions?.email_domains ?? []
|
||||||
|
|
||||||
/** Домен после «@» не совпадает (без учёта регистра) с эталонным — `null`, если сверять не с чем. */
|
/** Домен после «@» не совпадает (без учёта регистра) ни с одним эталонным — `null`, если сверять не с чем. */
|
||||||
function checkEmailDomain(value: string): string | null {
|
function checkEmailDomain(value: string): string | null {
|
||||||
if (!expectedEmailDomain) return null
|
if (expectedEmailDomains.length === 0) return null
|
||||||
const atIndex = value.lastIndexOf('@')
|
const atIndex = value.lastIndexOf('@')
|
||||||
if (atIndex === -1) return null
|
if (atIndex === -1) return null
|
||||||
const domain = value.slice(atIndex + 1).trim().toLowerCase()
|
const domain = value.slice(atIndex + 1).trim().toLowerCase()
|
||||||
if (domain !== expectedEmailDomain.toLowerCase()) {
|
if (!expectedEmailDomains.some((expected) => domain === expected.toLowerCase())) {
|
||||||
return domainMismatchMessage(expectedEmailDomain)
|
return domainMismatchMessage(expectedEmailDomains)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -68,7 +70,7 @@ export function RegisterPage() {
|
|||||||
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(expectedEmailDomain ? domainMismatchMessage(expectedEmailDomain) : 'Регистрация с этим доменом почты недоступна')
|
setEmailDomainError(expectedEmailDomains.length > 0 ? domainMismatchMessage(expectedEmailDomains) : 'Регистрация с этим доменом почты недоступна')
|
||||||
} else {
|
} else {
|
||||||
setError('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
|
setError('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { RoomStage } from '@/components/room/RoomStage'
|
|||||||
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
||||||
import { ChatPanel } from '@/components/room/ChatPanel'
|
import { ChatPanel } from '@/components/room/ChatPanel'
|
||||||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||||||
|
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||||
|
|
||||||
interface RoomJoinState {
|
interface RoomJoinState {
|
||||||
livekitUrl: string
|
livekitUrl: string
|
||||||
@@ -163,6 +164,12 @@ export function RoomPage() {
|
|||||||
() => ({
|
() => ({
|
||||||
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
|
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
|
||||||
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
|
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
|
||||||
|
// Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через
|
||||||
|
// usePersistentUserChoices: LocalUserChoices LiveKit про него не знает
|
||||||
|
// (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании —
|
||||||
|
// как и с audio/videoDeviceId, смена применяется через DeviceSettingsDialog
|
||||||
|
// (setActiveMediaDevice), а не пересозданием roomOptions.
|
||||||
|
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
|
||||||
}),
|
}),
|
||||||
[userChoices],
|
[userChoices],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,7 +27,11 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle at 30% 30%, rgba(212, 242, 227, 0.45), rgba(212, 242, 227, 0) 70%);
|
background: radial-gradient(circle at 30% 30%, rgba(212, 242, 227, 0.45), rgba(212, 242, 227, 0) 70%);
|
||||||
}
|
}
|
||||||
.brand-copy { z-index: 1; }
|
/* `margin-block: auto` держит блок по центру свободной высоты панели. Нужно
|
||||||
|
с тех пор, как из панели убрали нижнюю строку копирайта (она переехала в
|
||||||
|
общий футер): у `.brand-panel` осталось два потомка вместо трёх, и
|
||||||
|
`justify-content: space-between` иначе прижал бы текст к самому низу. */
|
||||||
|
.brand-copy { z-index: 1; margin-block: auto; }
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
font: var(--text-caption);
|
font: var(--text-caption);
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
|||||||
@@ -378,6 +378,48 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
.room-field select:focus { outline: none; border-color: var(--color-room-focus-ring); }
|
.room-field select:focus { outline: none; border-color: var(--color-room-focus-ring); }
|
||||||
|
.room-field-label { display: block; font: var(--text-body); font-weight: 600; color: var(--color-room-text-primary); margin-bottom: var(--space-2); }
|
||||||
|
.room-field-hint { font: var(--text-body); color: var(--color-room-text-secondary); margin: 0; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------- Шторка «Настройки устройств» на мобильном ----------
|
||||||
|
* Тот же диалог (`DeviceSettingsDialog`), что модалка выше, — только контейнер
|
||||||
|
* снизу вместо центра экрана: `.room-sheet-overlay`/`.room-sheet-panel` вместо
|
||||||
|
* `.room-modal-overlay`/`.room-modal-panel`, разметка полей (`.room-field` и
|
||||||
|
* ниже) общая. Ручка `.room-sheet-handle` — свайп вниз для закрытия (JS считает
|
||||||
|
* смещение пальца и сам решает, закрывать или вернуть панель на место;
|
||||||
|
* `transition` тут — только пружина возврата, во время самого драга
|
||||||
|
* компонент подставляет инлайновый `transition: none`).
|
||||||
|
*/
|
||||||
|
.room-sheet-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.room-sheet-panel {
|
||||||
|
background: var(--color-room-surface-raised);
|
||||||
|
border: 1px solid var(--color-room-tile-border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
box-shadow: var(--shadow-room-panel);
|
||||||
|
padding: var(--space-4) var(--space-6) calc(var(--space-6) + env(safe-area-inset-bottom));
|
||||||
|
width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: transform 160ms ease-out;
|
||||||
|
}
|
||||||
|
.room-sheet-handle {
|
||||||
|
width: 40px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-room-tile-border);
|
||||||
|
margin: 0 auto var(--space-4);
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ---------- Чип «Вы демонстрируете экран» ----------
|
* ---------- Чип «Вы демонстрируете экран» ----------
|
||||||
|
|||||||
Reference in New Issue
Block a user