mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-05 13:01:37 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 427a383dfe | |||
| d2f21d3617 | |||
| f0d5d652d1 | |||
| f4217b5b9b | |||
| c075db25a4 | |||
| c0dfd88776 | |||
| 94d2b80c17 | |||
| bd2dfcae80 | |||
| 0b4a83c92e | |||
| 9187173cae | |||
| 364bbf4f0b | |||
| a6a12ef586 | |||
| 2622d89f63 | |||
| f2d50770cf | |||
| 11e8470aa5 | |||
| 3bf78f0f5b | |||
| ddd5e3fce1 | |||
| 5a9e1cb012 | |||
| c49cee3ab4 | |||
| bbc30de490 | |||
| 14b3395e1c |
@@ -88,6 +88,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create writable /data
|
||||
run: |
|
||||
@@ -159,6 +161,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
@@ -186,6 +190,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && npm ci --ignore-scripts
|
||||
@@ -196,6 +202,27 @@ jobs:
|
||||
- name: Check format
|
||||
run: cd src/frontend/ && npm run check
|
||||
|
||||
test-front:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && npm ci --ignore-scripts
|
||||
|
||||
- name: Run tests
|
||||
run: cd src/frontend/ && npm test
|
||||
|
||||
lint-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -206,6 +233,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
@@ -227,6 +256,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
+17
-1
@@ -10,8 +10,18 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) import environment variables from Secrets and ConfigMaps
|
||||
- 🔒(backend) throttle meeting link generation
|
||||
- 🔒️(backend) add a daily cap on room creation
|
||||
- 🔧(summary) add setting to control Sentry traces sampling rate
|
||||
- ✨(frontend) let signed-out visitors start a meeting
|
||||
- ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration
|
||||
- ✅(frontend) add vitest so the frontend can carry unit tests
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(frontend) warn users when the connection falls back to TURN
|
||||
- 🔧(backend) configure the technical documentation url
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -19,6 +29,13 @@ and this project adheres to
|
||||
- 🔒️(agents) fix util-linux CVEs reported by Cyberwatch
|
||||
- 🔒️(backend) fix HIGH CVEs in Django and urllib3
|
||||
- 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111
|
||||
- 🔒️(frontend) upgrade pcre2 to fix CVE-2026-103111
|
||||
- 🐛(summary) disable default S3 checksums for GCS-compatible storage
|
||||
- 🔒️(summary) redact meeting content from Sentry events
|
||||
- 🐛(frontend) hide tooltips until they have a computed placement
|
||||
- 🐛(brevo) use django-lasuite for marketing management
|
||||
- ♿️(frontend) expose loading state to assistive technology
|
||||
- 🐛(frontend) honour Keep hand raised when picture-in-picture is open
|
||||
|
||||
## [1.33.0] - 2026-09-30
|
||||
|
||||
@@ -149,7 +166,6 @@ and this project adheres to
|
||||
### Added
|
||||
|
||||
- ✨(any) let any authenticated user manage the lobby on trusted rooms
|
||||
|
||||
### Changed
|
||||
|
||||
- 📱(frontend) collapse mobile control bar items on narrow viewports
|
||||
|
||||
+2
-3
@@ -37,14 +37,13 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:22 AS mail-builder
|
||||
FROM node:22-alpine AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
WORKDIR /mail/app
|
||||
|
||||
RUN yarn install --frozen-lockfile && \
|
||||
yarn build
|
||||
RUN npm ci --ignore-scripts && npm run build
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
|
||||
@@ -260,7 +260,8 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
$(MAKE) test-back-parallel ARGS="$${args}" && \
|
||||
$(MAKE) test-summary ARGS="$${args}"
|
||||
$(MAKE) test-summary ARGS="$${args}" && \
|
||||
$(MAKE) test-frontend
|
||||
.PHONY: test
|
||||
|
||||
test-back: ## run back-end tests (pass extra pytest args via ARGS)
|
||||
@@ -278,6 +279,10 @@ test-summary: ## run summary tests (pass extra pytest args via ARGS)
|
||||
bin/pytest-summary $${args}
|
||||
.PHONY: test-summary
|
||||
|
||||
test-frontend: ## run the frontend unit tests
|
||||
cd $(PATH_FRONT) && npm test
|
||||
.PHONY: test-frontend
|
||||
|
||||
makemigrations: ## run django makemigrations for the Meet project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/>
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
|
||||
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||
<img alt="GitHub license" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||
</a>
|
||||
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
|
||||
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
|
||||
@@ -49,8 +49,8 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
|
||||
- Telephony integration
|
||||
- Secure participation with robust authentication and access control
|
||||
- Customizable frontend style
|
||||
- LiveKit Advances features including :
|
||||
- speaker detection
|
||||
- LiveKit advanced features including:
|
||||
- speaker detection
|
||||
- simulcast
|
||||
- end-to-end optimizations
|
||||
- selective subscription
|
||||
|
||||
+3
-2
@@ -153,6 +153,7 @@ services:
|
||||
target: frontend-production
|
||||
args:
|
||||
VITE_API_BASE_URL: "http://localhost:8071"
|
||||
VITE_MEDIA_BASE_URL: "http://localhost:8083"
|
||||
VITE_APP_TITLE: "LaSuite Meet"
|
||||
image: meet:frontend-development
|
||||
ports:
|
||||
@@ -172,7 +173,7 @@ services:
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:22
|
||||
image: node:22-alpine
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
@@ -271,7 +272,7 @@ services:
|
||||
- /app/.venv
|
||||
|
||||
redis-summary:
|
||||
image: redis
|
||||
image: redis:5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' && \
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
|
||||
@@ -4,6 +4,36 @@ server {
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
# Proxy auth for recordings (authorized by the recordings viewset)
|
||||
location /media/recordings/ {
|
||||
auth_request /media-auth-recordings;
|
||||
auth_request_set $authHeader $upstream_http_authorization;
|
||||
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||
|
||||
proxy_set_header Authorization $authHeader;
|
||||
proxy_set_header X-Amz-Date $authDate;
|
||||
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||
|
||||
proxy_pass http://garage:9000/meet-media-storage/recordings/;
|
||||
proxy_set_header Host garage:9000;
|
||||
proxy_hide_header Content-Disposition;
|
||||
add_header Content-Disposition "attachment";
|
||||
}
|
||||
|
||||
location = /media-auth-recordings {
|
||||
internal;
|
||||
proxy_pass http://app-dev:8000/api/v1.0/recordings/media-auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Original-URL $request_uri;
|
||||
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
}
|
||||
|
||||
# Proxy auth for media
|
||||
location /media/ {
|
||||
# Auth request configuration
|
||||
|
||||
@@ -347,6 +347,7 @@ These are the environmental options available on meet backend.
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
||||
| FRONTEND_TECHNICAL_DOCUMENTATION_URL | URL of the technical documentation (network prerequisites) linked from the footer and the connection test. If unset, both links are hidden | |
|
||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
|
||||
@@ -88,7 +88,7 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
# RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||
# RECORDING_ENCODING_DEFAULT_PROFILE=full
|
||||
|
||||
# Default encoding values independant of resolution/profile
|
||||
# Default encoding values independent of resolution/profile
|
||||
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
|
||||
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ def get_frontend_configuration(request):
|
||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
|
||||
},
|
||||
"allow_unregistered_rooms": settings.ALLOW_UNREGISTERED_ROOMS,
|
||||
"authenticated_users_can_edit_display_name": (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
),
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
import contextlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import (
|
||||
ImproperlyConfigured,
|
||||
SuspiciousOperation,
|
||||
ValidationError,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from lasuite.marketing.tasks import create_or_update_contact
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
|
||||
from core.models import User
|
||||
from core.services.marketing import (
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
)
|
||||
from core.validators import sub_validator
|
||||
|
||||
|
||||
@@ -67,25 +60,15 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
"""Pragmatic approach to newsletter signup during authentication flow.
|
||||
"""Add the user to the newsletter list on sign-in.
|
||||
|
||||
Details:
|
||||
1. Uses a very short timeout (1s) to prevent blocking the auth process
|
||||
2. Silently fails if the marketing service is down/slow to prioritize user experience
|
||||
3. Trade-off: May miss some signups but ensures auth flow remains fast
|
||||
|
||||
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
||||
Uses the team's standard Brevo integration, dispatching the contact
|
||||
creation/update as an asynchronous task to keep authentication fast.
|
||||
"""
|
||||
with contextlib.suppress(
|
||||
ContactCreationError, ImproperlyConfigured, ImportError
|
||||
):
|
||||
marketing_service = get_marketing_service()
|
||||
contact_data = ContactData(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
marketing_service.create_contact(
|
||||
contact_data, timeout=settings.BREVO_API_TIMEOUT
|
||||
)
|
||||
create_or_update_contact.delay(
|
||||
email=email,
|
||||
attributes={"VISIO_SOURCE": ["SIGNIN"]},
|
||||
)
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"""Marketing service in charge of pushing data for marketing automation."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Optional, Protocol
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import brevo_python
|
||||
import urllib3
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContactCreationError(Exception):
|
||||
"""Raised when the contact creation fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContactData:
|
||||
"""Contact data for marketing service integration."""
|
||||
|
||||
email: str
|
||||
attributes: Optional[Dict[str, str]] = None
|
||||
list_ids: Optional[List[int]] = None
|
||||
update_enabled: bool = True
|
||||
|
||||
|
||||
class MarketingServiceProtocol(Protocol):
|
||||
"""Interface for marketing automation service integrations."""
|
||||
|
||||
def create_contact(
|
||||
self, contact_data: ContactData, timeout: Optional[int] = None
|
||||
) -> dict:
|
||||
"""Create or update a contact.
|
||||
|
||||
Args:
|
||||
contact_data: Contact information and attributes
|
||||
timeout: API request timeout in seconds
|
||||
|
||||
Returns:
|
||||
dict: Service response
|
||||
|
||||
Raises:
|
||||
ContactCreationError: If contact creation fails
|
||||
"""
|
||||
|
||||
|
||||
class BrevoMarketingService:
|
||||
"""Brevo marketing automation integration.
|
||||
|
||||
Handles:
|
||||
- Contact management and segmentation
|
||||
- Marketing campaigns and automation
|
||||
- Email communications
|
||||
|
||||
Configuration via Django settings:
|
||||
- BREVO_API_KEY: API authentication
|
||||
- BREVO_API_CONTACT_LIST_IDS: Default contact lists
|
||||
- BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Brevo (ex-sendinblue) marketing service."""
|
||||
|
||||
if not settings.BREVO_API_KEY:
|
||||
raise ImproperlyConfigured("Brevo API key is required")
|
||||
|
||||
configuration = brevo_python.Configuration()
|
||||
configuration.api_key["api-key"] = settings.BREVO_API_KEY
|
||||
|
||||
self._api_client = brevo_python.ApiClient(configuration)
|
||||
|
||||
def create_contact(self, contact_data: ContactData, timeout=None) -> dict:
|
||||
"""Create or update a Brevo contact.
|
||||
|
||||
Args:
|
||||
contact_data: Contact information and attributes
|
||||
timeout: API request timeout in seconds
|
||||
|
||||
Returns:
|
||||
dict: Brevo API response
|
||||
|
||||
Raises:
|
||||
ContactCreationError: If contact creation fails
|
||||
ImproperlyConfigured: If required settings are missing
|
||||
|
||||
Note:
|
||||
Contact attributes must be pre-configured in Brevo.
|
||||
Changes to attributes can impact existing workflows.
|
||||
"""
|
||||
|
||||
if not settings.BREVO_API_CONTACT_LIST_IDS:
|
||||
raise ImproperlyConfigured(
|
||||
"Default Brevo List IDs must be configured in settings."
|
||||
)
|
||||
|
||||
contact_api = brevo_python.ContactsApi(self._api_client)
|
||||
|
||||
attributes = {
|
||||
**settings.BREVO_API_CONTACT_ATTRIBUTES,
|
||||
**(contact_data.attributes or {}),
|
||||
}
|
||||
|
||||
list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS
|
||||
|
||||
contact = brevo_python.CreateContact(
|
||||
email=contact_data.email,
|
||||
attributes=attributes,
|
||||
list_ids=list_ids,
|
||||
update_enabled=contact_data.update_enabled,
|
||||
)
|
||||
|
||||
api_configurations = {}
|
||||
|
||||
if timeout is not None:
|
||||
api_configurations["_request_timeout"] = timeout
|
||||
|
||||
try:
|
||||
response = contact_api.create_contact(contact, **api_configurations)
|
||||
except (
|
||||
brevo_python.rest.ApiException,
|
||||
urllib3.exceptions.ReadTimeoutError,
|
||||
) as err:
|
||||
logger.warning("Failed to create contact in Brevo", exc_info=True)
|
||||
raise ContactCreationError("Failed to create contact in Brevo") from err
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_marketing_service() -> MarketingServiceProtocol:
|
||||
"""Return cached instance of configured marketing service."""
|
||||
marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS)
|
||||
return marketing_service_cls()
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
from lasuite.marketing.tasks import create_or_update_contact
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.factories import UserFactory
|
||||
from core.services import marketing
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -606,8 +606,8 @@ def test_marketing_signup_existing_user(
|
||||
mock_signup.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_signup_to_marketing_email_success(mock_marketing):
|
||||
@mock.patch.object(create_or_update_contact, "delay")
|
||||
def test_signup_to_marketing_email_success(mock_create_or_update_contact):
|
||||
"""Test successful marketing signup."""
|
||||
|
||||
email = "test@example.com"
|
||||
@@ -616,46 +616,6 @@ def test_signup_to_marketing_email_success(mock_marketing):
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email(email)
|
||||
|
||||
# Verify service interaction
|
||||
mock_service = mock_marketing.return_value
|
||||
mock_service.create_contact.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
ImportError,
|
||||
ImproperlyConfigured,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_marketing_signup_handles_service_initialization_errors(
|
||||
mock_marketing, error, settings
|
||||
):
|
||||
"""Tests errors that occur when trying to get/initialize the marketing service."""
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
|
||||
mock_marketing.side_effect = error
|
||||
|
||||
# Should not raise any exception
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
marketing.ContactCreationError,
|
||||
ImproperlyConfigured,
|
||||
ImportError,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_marketing_signup_handles_contact_creation_errors(
|
||||
mock_marketing, error, settings
|
||||
):
|
||||
"""Tests errors that occur during the contact creation process."""
|
||||
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
mock_marketing.return_value.create_contact.side_effect = error
|
||||
|
||||
# Should not raise any exception
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
|
||||
mock_create_or_update_contact.assert_called_once_with(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
"""
|
||||
Test marketing services.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import brevo_python
|
||||
import pytest
|
||||
import urllib3
|
||||
|
||||
from core.services.marketing import (
|
||||
BrevoMarketingService,
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
)
|
||||
|
||||
|
||||
def test_init_missing_api_key(settings):
|
||||
"""Test initialization with missing API key."""
|
||||
settings.BREVO_API_KEY = None
|
||||
with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"):
|
||||
BrevoMarketingService()
|
||||
|
||||
|
||||
def test_create_contact_missing_list_ids(settings):
|
||||
"""Test contact creation with missing list IDs."""
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = None
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured, match="Default Brevo List IDs must be configured"
|
||||
):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_success(mock_contact_api):
|
||||
"""Test successful contact creation."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
mock_api.create_contact.return_value = {"id": "test-id"}
|
||||
response = brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
assert response == {"id": "test-id"}
|
||||
|
||||
mock_api.create_contact.assert_called_once()
|
||||
contact_arg = mock_api.create_contact.call_args[0][0]
|
||||
assert contact_arg.email == "test@example.com"
|
||||
assert contact_arg.attributes == {
|
||||
**settings.BREVO_API_CONTACT_ATTRIBUTES,
|
||||
**valid_contact_data.attributes,
|
||||
}
|
||||
assert set(contact_arg.list_ids) == {1, 2, 3, 4}
|
||||
assert contact_arg.update_enabled is True
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_with_timeout(mock_contact_api):
|
||||
"""Test contact creation with timeout."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
brevo_service.create_contact(valid_contact_data, timeout=30)
|
||||
|
||||
mock_api.create_contact.assert_called_once()
|
||||
assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_api_error(mock_contact_api):
|
||||
"""Test contact creation API error handling."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
mock_api.create_contact.side_effect = brevo_python.rest.ApiException()
|
||||
|
||||
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_timeout_error(mock_contact_api):
|
||||
"""Test contact creation timeout error handling."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
mock_api.create_contact.side_effect = urllib3.exceptions.ReadTimeoutError(
|
||||
pool=mock.Mock(),
|
||||
url="https://api.brevo.com/v3/endpoint",
|
||||
message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.",
|
||||
)
|
||||
|
||||
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clear_marketing_cache():
|
||||
"""Clear marketing service cache between tests."""
|
||||
get_marketing_service.cache_clear()
|
||||
yield
|
||||
get_marketing_service.cache_clear()
|
||||
|
||||
|
||||
def test_get_marketing_service_caching(clear_marketing_cache):
|
||||
"""Test marketing service caching behavior."""
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
|
||||
|
||||
service1 = get_marketing_service()
|
||||
service2 = get_marketing_service()
|
||||
|
||||
assert service1 is service2
|
||||
assert isinstance(service1, BrevoMarketingService)
|
||||
|
||||
|
||||
def test_get_marketing_service_invalid_class(clear_marketing_cache):
|
||||
"""Test handling of invalid service class."""
|
||||
settings.MARKETING_SERVICE_CLASS = "invalid.service.path"
|
||||
|
||||
with pytest.raises(ImportError):
|
||||
get_marketing_service()
|
||||
|
||||
|
||||
@mock.patch("core.services.marketing.import_string")
|
||||
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
|
||||
"""Test service class is instantiated only once."""
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
|
||||
get_marketing_service.cache_clear()
|
||||
|
||||
mock_service_cls = mock.Mock()
|
||||
mock_service_instance = mock.Mock()
|
||||
mock_service_cls.return_value = mock_service_instance
|
||||
mock_import_string.return_value = mock_service_cls
|
||||
|
||||
service1 = get_marketing_service()
|
||||
service2 = get_marketing_service()
|
||||
|
||||
mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS)
|
||||
mock_service_cls.assert_called_once()
|
||||
assert service1 is service2
|
||||
assert service1 is mock_service_instance
|
||||
@@ -466,6 +466,11 @@ class Base(Configuration):
|
||||
"documentation_url": values.Value(
|
||||
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
|
||||
),
|
||||
"technical_documentation_url": values.Value(
|
||||
None,
|
||||
environ_name="FRONTEND_TECHNICAL_DOCUMENTATION_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"external_home_url": values.Value(
|
||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||
),
|
||||
@@ -933,24 +938,18 @@ class Base(Configuration):
|
||||
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
MARKETING_SERVICE_CLASS = values.Value(
|
||||
"core.services.marketing.BrevoMarketingService",
|
||||
environ_name="MARKETING_SERVICE_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
BREVO_API_KEY = SecretFileValue(
|
||||
None, environ_name="BREVO_API_KEY", environ_prefix=None
|
||||
)
|
||||
BREVO_API_CONTACT_LIST_IDS = values.ListValue(
|
||||
[],
|
||||
environ_name="BREVO_API_CONTACT_LIST_IDS",
|
||||
environ_prefix=None,
|
||||
converter=int,
|
||||
)
|
||||
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
|
||||
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
|
||||
1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
LASUITE_MARKETING = {
|
||||
"BACKEND": values.Value(
|
||||
"lasuite.marketing.backends.dummy.DummyBackend",
|
||||
environ_name="LASUITE_MARKETING_BACKEND",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"PARAMETERS": values.DictValue(
|
||||
default={},
|
||||
environ_name="LASUITE_MARKETING_PARAMETERS",
|
||||
environ_prefix=None,
|
||||
),
|
||||
}
|
||||
|
||||
# Lobby configurations
|
||||
PRESENCE_KEY_PREFIX = values.Value(
|
||||
|
||||
@@ -39,6 +39,9 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
ARG VITE_APP_TITLE
|
||||
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
|
||||
|
||||
ARG VITE_MEDIA_BASE_URL
|
||||
ENV VITE_MEDIA_BASE_URL=${VITE_MEDIA_BASE_URL}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
@@ -46,6 +49,7 @@ FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' && \
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
|
||||
Generated
+311
-36
@@ -31,11 +31,11 @@
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.418.10",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-aria": "3.51.0",
|
||||
"react-aria-components": "1.20.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.12",
|
||||
"react-stately": "3.48.0",
|
||||
"react-stately": "3.49.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
"wouter": "3.10.0"
|
||||
@@ -64,7 +64,8 @@
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-static-copy": "4.1.1",
|
||||
"vite-plugin-svgr": "5.2.0"
|
||||
"vite-plugin-svgr": "5.2.0",
|
||||
"vitest": "5.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/react-spectrum": {
|
||||
@@ -883,9 +884,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@internationalized/date": {
|
||||
"version": "3.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz",
|
||||
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
|
||||
"version": "3.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz",
|
||||
"integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
@@ -901,9 +902,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@internationalized/string": {
|
||||
"version": "3.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.9.tgz",
|
||||
"integrity": "sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==",
|
||||
"version": "3.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.10.tgz",
|
||||
"integrity": "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
@@ -942,9 +943,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1819,9 +1820,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.36.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
|
||||
"integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
|
||||
"version": "3.36.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz",
|
||||
"integrity": "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
@@ -2506,6 +2507,24 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-record": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||
@@ -2786,6 +2805,64 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz",
|
||||
"integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@vitest/spy": "5.0.2",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^1.2.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/magic-string": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
|
||||
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz",
|
||||
"integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
|
||||
@@ -3151,6 +3228,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types-flow": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||
@@ -4130,6 +4217,16 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -5030,6 +5127,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
@@ -5532,6 +5636,16 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
@@ -8647,6 +8761,20 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -9384,19 +9512,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-aria": {
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
|
||||
"integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
|
||||
"version": "3.51.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.51.0.tgz",
|
||||
"integrity": "sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@internationalized/date": "^3.12.3",
|
||||
"@internationalized/number": "^3.6.7",
|
||||
"@internationalized/string": "^3.2.9",
|
||||
"@react-types/shared": "^3.36.0",
|
||||
"@internationalized/string": "^3.2.10",
|
||||
"@react-types/shared": "^3.36.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"aria-hidden": "^1.2.3",
|
||||
"clsx": "^2.0.0",
|
||||
"react-stately": "3.48.0",
|
||||
"react-stately": "3.49.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -9405,17 +9533,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-aria-components": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
|
||||
"integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.20.0.tgz",
|
||||
"integrity": "sha512-BMbpIgoV9aELeBrB0Y120NgoigHb5OdcJwc+4e7uSnbTbamea6lo+gqcc4LAxzMaK3Jf+7LI1oCDE6yANsmxIQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@react-types/shared": "^3.36.0",
|
||||
"@internationalized/date": "^3.12.3",
|
||||
"@internationalized/string": "^3.2.10",
|
||||
"@react-types/shared": "^3.36.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"client-only": "^0.0.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-stately": "3.48.0"
|
||||
"react-aria": "3.51.0",
|
||||
"react-stately": "3.49.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
@@ -9469,15 +9598,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-stately": {
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
|
||||
"integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.49.0.tgz",
|
||||
"integrity": "sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@internationalized/date": "^3.12.3",
|
||||
"@internationalized/number": "^3.6.7",
|
||||
"@internationalized/string": "^3.2.9",
|
||||
"@react-types/shared": "^3.36.0",
|
||||
"@internationalized/string": "^3.2.10",
|
||||
"@react-types/shared": "^3.36.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
@@ -10219,6 +10348,13 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
|
||||
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -10525,6 +10661,26 @@
|
||||
"xtend": "~4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz",
|
||||
"integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
|
||||
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -11609,6 +11765,112 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz",
|
||||
"integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/mocker": "5.0.2",
|
||||
"chai": "^6.2.2",
|
||||
"es-module-lexer": "^2.3.2",
|
||||
"expect-type": "^1.4.0",
|
||||
"magic-string": "^1.2.3",
|
||||
"obug": "^2.1.4",
|
||||
"picomatch": "^4.0.7",
|
||||
"std-env": "^4.2.0",
|
||||
"tinybench": "^6.1.4",
|
||||
"tinyexec": "^1.3.0",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"why-is-node-running": "^3.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "5.0.2",
|
||||
"@vitest/browser-preview": "5.0.2",
|
||||
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||
"@vitest/coverage-istanbul": "5.0.2",
|
||||
"@vitest/coverage-v8": "5.0.2",
|
||||
"@vitest/ui": "5.0.2",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/magic-string": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
|
||||
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/walk-sync": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
|
||||
@@ -11793,6 +12055,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz",
|
||||
"integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"dev": "panda codegen && vite",
|
||||
"build": "panda codegen && tsc -b && vite build",
|
||||
"build:debug": "VITE_ANALYZE=true npm run build -- --debug",
|
||||
"test": "panda codegen && vitest run",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"preview": "vite preview",
|
||||
@@ -38,11 +39,11 @@
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.418.10",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-aria": "3.51.0",
|
||||
"react-aria-components": "1.20.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.12",
|
||||
"react-stately": "3.48.0",
|
||||
"react-stately": "3.49.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
"wouter": "3.10.0"
|
||||
@@ -71,6 +72,7 @@
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-static-copy": "4.1.1",
|
||||
"vite-plugin-svgr": "5.2.0"
|
||||
"vite-plugin-svgr": "5.2.0",
|
||||
"vitest": "5.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const mediaUrl = (path: string) => {
|
||||
const origin =
|
||||
import.meta.env.VITE_API_BASE_URL ||
|
||||
import.meta.env.VITE_MEDIA_BASE_URL ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '')
|
||||
|
||||
// Remove leading/trailing slashes from origin/path if it exists
|
||||
|
||||
@@ -22,12 +22,14 @@ export interface ApiConfig {
|
||||
url: string
|
||||
}
|
||||
documentation_url?: string
|
||||
technical_documentation_url?: string
|
||||
external_home_url?: string
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
custom_css_url?: string
|
||||
use_french_gov_footer?: boolean
|
||||
use_proconnect_button?: boolean
|
||||
allow_unregistered_rooms?: boolean
|
||||
idle_disconnect_warning_delay?: number
|
||||
recording?: {
|
||||
is_enabled?: boolean
|
||||
|
||||
@@ -19,7 +19,9 @@ export const LoadingScreen = ({
|
||||
<Screen layout={layout} header={header} footer={footer}>
|
||||
<CenteredContent>
|
||||
<Center>
|
||||
<p>{t('loading')}</p>
|
||||
<p role="status" aria-live="polite">
|
||||
{t('loading')}
|
||||
</p>
|
||||
</Center>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
|
||||
@@ -22,6 +22,12 @@ export type IceCandidateInfo = {
|
||||
port?: number
|
||||
/** Local candidates only, and not reported by every browser. */
|
||||
networkType?: string
|
||||
/**
|
||||
* For a local relay candidate, the TURN URL it was gathered from
|
||||
* (e.g. `turns:turn.example.com:443?transport=tcp`). Used as a fallback
|
||||
* when the browser does not report `relayProtocol`.
|
||||
*/
|
||||
url?: string
|
||||
}
|
||||
|
||||
export type IceCandidatePair = {
|
||||
@@ -42,6 +48,57 @@ export type IceCandidateReport = {
|
||||
working: IceCandidatePair[]
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null
|
||||
|
||||
/** Narrows the loosely typed `data` stored on a step result. */
|
||||
export const isIceCandidateReport = (
|
||||
data: unknown
|
||||
): data is IceCandidateReport =>
|
||||
isObject(data) &&
|
||||
Array.isArray(data.working) &&
|
||||
(data.selected === null ||
|
||||
(isObject(data.selected) && isObject(data.selected.local)))
|
||||
|
||||
/**
|
||||
* Transport between the browser and the TURN server for a local relay
|
||||
* candidate: udp, tcp or tls, or undefined when it cannot be determined.
|
||||
*
|
||||
* `protocol` is deliberately not used here: on a relay candidate it describes
|
||||
* the TURN allocation (server to peer), which is UDP even when the client
|
||||
* reaches the TURN server over TLS.
|
||||
*/
|
||||
export const getRelayTransport = (
|
||||
candidate: IceCandidateInfo
|
||||
): string | undefined => {
|
||||
if (candidate.relayProtocol) return candidate.relayProtocol.toLowerCase()
|
||||
if (!candidate.url) return undefined
|
||||
|
||||
const url = candidate.url.toLowerCase()
|
||||
if (url.startsWith('turns:')) return 'tls'
|
||||
if (!url.startsWith('turn:')) return undefined
|
||||
const transport = /[?&]transport=(udp|tcp)\b/.exec(url)?.[1]
|
||||
// RFC 7065: a turn: URI without a transport parameter defaults to UDP.
|
||||
return transport ?? 'udp'
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the selected pair goes through a TURN relay reached over TCP or
|
||||
* TLS. Media still flows, but TCP head-of-line blocking usually degrades
|
||||
* audio and video under packet loss.
|
||||
*
|
||||
* Direct routes (host, srflx, prflx), including ICE-TCP to the SFU, are out of
|
||||
* scope: the warning and its documentation are about TURN fallbacks.
|
||||
* An undetermined transport is not evidence of a bad route.
|
||||
*/
|
||||
export const isRelayedOverTcp = (data: unknown): boolean => {
|
||||
if (!isIceCandidateReport(data) || !data.selected) return false
|
||||
const { local } = data.selected
|
||||
if (local.type !== 'relay') return false
|
||||
const transport = getRelayTransport(local)
|
||||
return transport === 'tcp' || transport === 'tls'
|
||||
}
|
||||
|
||||
const PROBE_WIDTH = 320
|
||||
const PROBE_HEIGHT = 180
|
||||
const PROBE_FPS = 15
|
||||
@@ -57,6 +114,7 @@ const readCandidate = (stats?: Stats): IceCandidateInfo => {
|
||||
protocol: stats.protocol as string | undefined,
|
||||
relayProtocol: stats.relayProtocol as string | undefined,
|
||||
networkType: stats.networkType as string | undefined,
|
||||
url: stats.url as string | undefined,
|
||||
...(INCLUDE_CANDIDATE_ADDRESSES
|
||||
? {
|
||||
address: stats.address as string | undefined,
|
||||
@@ -67,7 +125,10 @@ const readCandidate = (stats?: Stats): IceCandidateInfo => {
|
||||
}
|
||||
|
||||
const describeCandidate = (candidate: IceCandidateInfo) => {
|
||||
const transport = candidate.relayProtocol ?? candidate.protocol ?? 'unknown'
|
||||
const transport =
|
||||
(candidate.type === 'relay' ? getRelayTransport(candidate) : undefined) ??
|
||||
candidate.protocol ??
|
||||
'unknown'
|
||||
const endpoint =
|
||||
candidate.address === undefined
|
||||
? ''
|
||||
|
||||
@@ -2,18 +2,44 @@ import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ProgressBar } from 'react-aria-components'
|
||||
import { css, cx } from '@/styled-system/css'
|
||||
import { A } from '@/primitives'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import type { ConnectionTestStats } from '../types'
|
||||
import { statusSquareClass } from './stepAppearance'
|
||||
|
||||
type SummaryState = 'idle' | 'running' | 'passed' | 'partial' | 'failed'
|
||||
type SummaryState =
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'passed'
|
||||
| 'partial'
|
||||
| 'failed'
|
||||
| 'warning'
|
||||
|
||||
/** Only a failure earns a colour: everything else stays near-black. */
|
||||
/** Only a failure or a degraded route earns a colour: everything else stays near-black. */
|
||||
const stateColorClass: Record<SummaryState, string> = {
|
||||
idle: css({ color: 'greyscale.1000' }),
|
||||
running: css({ color: 'greyscale.1000' }),
|
||||
passed: css({ color: 'greyscale.1000' }),
|
||||
partial: css({ color: 'greyscale.1000' }),
|
||||
failed: css({ color: 'danger.600' }),
|
||||
warning: css({ color: 'warning' }),
|
||||
}
|
||||
|
||||
/**
|
||||
* A hard failure still outranks a warning step; a warning outranks 'partial'
|
||||
* because a measured degraded route matters more than skipped camera or
|
||||
* microphone checks.
|
||||
*/
|
||||
const getSummaryState = (
|
||||
stats: ConnectionTestStats,
|
||||
isRunning: boolean
|
||||
): SummaryState => {
|
||||
if (isRunning) return 'running'
|
||||
if (!stats.hasStarted) return 'idle'
|
||||
if (stats.failed > 0) return 'failed'
|
||||
if (stats.warnings > 0) return 'warning'
|
||||
if (stats.skipped > 0) return 'partial'
|
||||
return 'passed'
|
||||
}
|
||||
|
||||
const cardClass = css({
|
||||
@@ -183,16 +209,15 @@ export const ConnectionTestSummary = ({
|
||||
children?: ReactNode
|
||||
}) => {
|
||||
const { t } = useTranslation('connectionTest')
|
||||
const { data: config } = useConfig()
|
||||
|
||||
const state: SummaryState = isRunning
|
||||
? 'running'
|
||||
: !stats.hasStarted
|
||||
? 'idle'
|
||||
: stats.failed > 0
|
||||
? 'failed'
|
||||
: stats.skipped > 0
|
||||
? 'partial'
|
||||
: 'passed'
|
||||
// Network prerequisites for the reader's IT department. Instance specific,
|
||||
// so it comes from the backend; without it the warning shows no link.
|
||||
const networkDocUrl = config?.technical_documentation_url
|
||||
|
||||
const state = getSummaryState(stats, isRunning)
|
||||
// Skipped device checks still deserve their hint under a route warning.
|
||||
const showPartialHint = state === 'warning' && stats.skipped > 0
|
||||
|
||||
return (
|
||||
<section className={cardClass}>
|
||||
@@ -206,7 +231,27 @@ export const ConnectionTestSummary = ({
|
||||
: t(`summary.${state}`)}
|
||||
</p>
|
||||
|
||||
<p className={hintClass}>{t(`summary.${state}Hint`)}</p>
|
||||
<p className={hintClass}>
|
||||
{t(`summary.${state}Hint`)}
|
||||
{state === 'warning' && networkDocUrl && (
|
||||
<>
|
||||
{' '}
|
||||
<A
|
||||
href={networkDocUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
externalIcon
|
||||
aria-label={t('summary.warningDocLinkAriaLabel')}
|
||||
>
|
||||
{t('summary.warningDocLink')}
|
||||
</A>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{showPartialHint && (
|
||||
<p className={hintClass}>{t('summary.partialHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats.hasStarted && (
|
||||
@@ -237,6 +282,13 @@ export const ConnectionTestSummary = ({
|
||||
value={stats.passed}
|
||||
label={t('counts.passed')}
|
||||
/>
|
||||
{stats.warnings > 0 && (
|
||||
<Counter
|
||||
squareClass={statusSquareClass.warning}
|
||||
value={stats.warnings}
|
||||
label={t('counts.warnings')}
|
||||
/>
|
||||
)}
|
||||
<Counter
|
||||
squareClass={statusSquareClass.skipped}
|
||||
value={stats.skipped}
|
||||
|
||||
@@ -15,15 +15,20 @@ export const statusSquareClass: Record<ConnectionTestStepStatus, string> = {
|
||||
animation: 'pulse_background 1.2s ease-in-out infinite',
|
||||
}),
|
||||
success: css({ backgroundColor: 'success.600' }),
|
||||
warning: css({ backgroundColor: 'warning' }),
|
||||
failed: css({ backgroundColor: 'danger.600' }),
|
||||
skipped: css({ backgroundColor: 'greyscale.300' }),
|
||||
}
|
||||
|
||||
/** Colour is carried by the square; the label stays near-black except on failure. */
|
||||
/**
|
||||
* Colour is carried by the square; the label stays near-black except on
|
||||
* failure and warning.
|
||||
*/
|
||||
export const statusTextClass: Record<ConnectionTestStepStatus, string> = {
|
||||
pending: css({ color: 'greyscale.500' }),
|
||||
running: css({ color: 'greyscale.700' }),
|
||||
success: css({ color: 'greyscale.1000' }),
|
||||
warning: css({ color: 'warning', fontWeight: 'medium' }),
|
||||
failed: css({ color: 'danger.600', fontWeight: 'medium' }),
|
||||
skipped: css({ color: 'greyscale.500' }),
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
type CheckInfo,
|
||||
} from 'livekit-client'
|
||||
import { fetchConnectionTestDetails } from '../api/fetchConnectionTestDetails'
|
||||
import { SelectedCandidateCheck } from '../checks/selectedCandidate'
|
||||
import {
|
||||
isRelayedOverTcp,
|
||||
SelectedCandidateCheck,
|
||||
} from '../checks/selectedCandidate'
|
||||
import {
|
||||
createInitialSteps,
|
||||
type ConnectionTestLog,
|
||||
@@ -49,10 +52,23 @@ const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
|
||||
const isPermissionError = (error: unknown) =>
|
||||
error instanceof Error && PERMISSION_ERROR_NAMES.has(error.name)
|
||||
|
||||
const toStepStatus = (info: CheckInfo): ConnectionTestStepStatus => {
|
||||
const status = CHECK_STATUS_TO_STEP[info.status] ?? 'failed'
|
||||
return status === 'success' && isRelayedOverTcp(info.data)
|
||||
? 'warning'
|
||||
: status
|
||||
}
|
||||
|
||||
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
|
||||
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
|
||||
status: toStepStatus(info),
|
||||
summary: info.description,
|
||||
logs: info.logs,
|
||||
// Only SelectedCandidateCheck sets `data` (the ICE candidate report).
|
||||
// Consumers narrow it with a type guard (see isIceCandidateReport).
|
||||
data:
|
||||
typeof info.data === 'object' && info.data !== null
|
||||
? (info.data as Record<string, unknown>)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ConnectionTestStepStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'failed'
|
||||
| 'skipped'
|
||||
|
||||
@@ -63,6 +64,7 @@ export type ConnectionTestStats = {
|
||||
total: number
|
||||
settled: number
|
||||
passed: number
|
||||
warnings: number
|
||||
failed: number
|
||||
skipped: number
|
||||
hasStarted: boolean
|
||||
@@ -77,24 +79,27 @@ export const summarizeSteps = (
|
||||
steps: ConnectionTestStepResult[]
|
||||
): ConnectionTestStats => {
|
||||
let passed = 0
|
||||
let warnings = 0
|
||||
let failed = 0
|
||||
let skipped = 0
|
||||
let pending = 0
|
||||
|
||||
for (const step of steps) {
|
||||
if (step.status === 'success') passed += 1
|
||||
else if (step.status === 'warning') warnings += 1
|
||||
else if (step.status === 'failed') failed += 1
|
||||
else if (step.status === 'skipped') skipped += 1
|
||||
else if (step.status === 'pending') pending += 1
|
||||
}
|
||||
|
||||
const total = steps.length
|
||||
const settled = passed + failed + skipped
|
||||
const settled = passed + warnings + failed + skipped
|
||||
|
||||
return {
|
||||
total,
|
||||
settled,
|
||||
passed,
|
||||
warnings,
|
||||
failed,
|
||||
skipped,
|
||||
hasStarted: pending < total,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/primitives'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { generateRoomId } from '@/features/rooms'
|
||||
|
||||
export const CreateUnregisteredMeetingButton = () => {
|
||||
const { t } = useTranslation('home')
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
data-attr="create-unregistered-meeting"
|
||||
onPress={() =>
|
||||
navigateTo('room', generateRoomId(), { state: { create: true } })
|
||||
}
|
||||
>
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||
import { IntroSlider } from '../components/IntroSlider'
|
||||
import { MoreLink } from '../components/MoreLink'
|
||||
import { CreateMeetingMenu } from '../components/CreateMeetingMenu'
|
||||
import { CreateUnregisteredMeetingButton } from '../components/CreateUnregisteredMeetingButton'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -189,13 +190,19 @@ const Home = () => {
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
flexDirection: { base: 'column', xsm: 'row' },
|
||||
flexWrap: 'wrap',
|
||||
alignItems: { base: 'center', xsm: 'items-start' },
|
||||
})}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<CreateMeetingMenu />
|
||||
) : (
|
||||
<LoginButton proConnectHint={false} />
|
||||
<>
|
||||
{data?.allow_unregistered_rooms && (
|
||||
<CreateUnregisteredMeetingButton />
|
||||
)}
|
||||
<LoginButton proConnectHint={false} />
|
||||
</>
|
||||
)}
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
|
||||
@@ -40,11 +40,15 @@ const StyledRACDialog = styled(Dialog, {
|
||||
})
|
||||
|
||||
export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||
|
||||
const roomData = useRoomData()
|
||||
|
||||
const isCreatingUnregisteredRoom =
|
||||
roomData?.id === null && !!history.state?.create
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
const showInviteDialog =
|
||||
!isDismissed && (mode === 'create' || isCreatingUnregisteredRoom)
|
||||
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
|
||||
|
||||
const telephony = useTelephony()
|
||||
@@ -78,7 +82,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={() => {
|
||||
setShowInviteDialog(false)
|
||||
setIsDismissed(true)
|
||||
}}
|
||||
aria-label={t('closeDialog')}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import {
|
||||
closeLowerHandToasts,
|
||||
showLowerHandToast,
|
||||
} from '@/features/notifications/utils'
|
||||
|
||||
const SPEAKING_DETECTION_DELAY = 3000
|
||||
|
||||
/**
|
||||
* Offers to lower the local participant's raised hand after
|
||||
* SPEAKING_DETECTION_DELAY of speaking. Mount it once: each copy runs its own
|
||||
* timer and shows its own toast.
|
||||
*/
|
||||
export const LowerHandOnSpeaking = () => {
|
||||
const room = useRoomContext()
|
||||
const { isHandRaised, lowerHand } = useRaisedHand({
|
||||
participant: room.localParticipant,
|
||||
})
|
||||
const isSpeaking = useIsSpeaking(room.localParticipant)
|
||||
const [hasOffered, setHasOffered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isHandRaised) return
|
||||
setHasOffered(false)
|
||||
closeLowerHandToasts()
|
||||
}, [isHandRaised])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSpeaking || !isHandRaised || hasOffered) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setHasOffered(true)
|
||||
showLowerHandToast(room.localParticipant, lowerHand)
|
||||
}, SPEAKING_DETECTION_DELAY)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSpeaking, isHandRaised, hasOffered])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -2,19 +2,12 @@ import { useTranslation } from 'react-i18next'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
closeLowerHandToasts,
|
||||
showLowerHandToast,
|
||||
} from '@/features/notifications/utils'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
const SPEAKING_DETECTION_DELAY = 3000
|
||||
|
||||
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
|
||||
|
||||
export const HandToggle = ({
|
||||
@@ -25,64 +18,15 @@ export const HandToggle = ({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
||||
|
||||
const room = useRoomContext()
|
||||
const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({
|
||||
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
|
||||
participant: room.localParticipant,
|
||||
})
|
||||
|
||||
const isSpeaking = useIsSpeaking(room.localParticipant)
|
||||
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [hasShownToast, setHasShownToast] = useState(false)
|
||||
|
||||
const resetToastState = () => {
|
||||
setHasShownToast(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isHandRaised) return
|
||||
closeLowerHandToasts()
|
||||
}, [isHandRaised])
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleRaisedHand()
|
||||
resetToastState()
|
||||
}
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'raise-hand',
|
||||
handler: handleToggle,
|
||||
handler: toggleRaisedHand,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
|
||||
|
||||
if (shouldShowToast && !speakingTimerRef.current) {
|
||||
speakingTimerRef.current = setTimeout(() => {
|
||||
speakingTimerRef.current = null
|
||||
setHasShownToast(true)
|
||||
const onClose = () => {
|
||||
lowerHand()
|
||||
resetToastState()
|
||||
}
|
||||
showLowerHandToast(room.localParticipant, onClose)
|
||||
}, SPEAKING_DETECTION_DELAY)
|
||||
}
|
||||
if ((!isSpeaking || !isHandRaised) && speakingTimerRef.current) {
|
||||
clearTimeout(speakingTimerRef.current)
|
||||
speakingTimerRef.current = null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSpeaking, isHandRaised, hasShownToast, lowerHand])
|
||||
|
||||
// Clear any pending timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (speakingTimerRef.current) {
|
||||
clearTimeout(speakingTimerRef.current)
|
||||
speakingTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
|
||||
|
||||
return (
|
||||
@@ -100,7 +44,7 @@ export const HandToggle = ({
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isHandRaised}
|
||||
onPress={(e) => {
|
||||
handleToggle()
|
||||
toggleRaisedHand()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-hand-${tooltipLabel}`}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
|
||||
import { ChatProvider } from '@/features/chat/components/ChatProvider'
|
||||
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
|
||||
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
|
||||
import { LowerHandOnSpeaking } from '@/features/rooms/livekit/components/LowerHandOnSpeaking'
|
||||
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
|
||||
|
||||
/**
|
||||
@@ -119,6 +120,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<ConnectionObserver />
|
||||
<SyncDevicePreferences />
|
||||
<RoomSilentMicDetector />
|
||||
<LowerHandOnSpeaking />
|
||||
<MediaStateObserver />
|
||||
<ChatProvider />
|
||||
<LobbyProvider />
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeRoomId } from './isRoomValid'
|
||||
|
||||
describe('normalizeRoomId', () => {
|
||||
it('lowercases and re-inserts the hyphens of a ten-letter id', () => {
|
||||
expect(normalizeRoomId('ABCDEFGHIJ')).toBe('abc-defg-hij')
|
||||
expect(normalizeRoomId('abc-defghij')).toBe('abc-defg-hij')
|
||||
})
|
||||
|
||||
it('returns any other input unchanged', () => {
|
||||
expect(normalizeRoomId('abc-def')).toBe('abc-def')
|
||||
expect(normalizeRoomId('Not-A-Room')).toBe('Not-A-Room')
|
||||
})
|
||||
})
|
||||
@@ -126,6 +126,9 @@ export const Footer = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const isConnectionTestEnabled = !!data.diagnostics?.connection_test_enabled
|
||||
const technicalDocumentationUrl = data.technical_documentation_url
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={css({
|
||||
@@ -256,7 +259,9 @@ export const Footer = () => {
|
||||
{t('links.data')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
<StyledLi divider>
|
||||
<StyledLi
|
||||
divider={isConnectionTestEnabled || !!technicalDocumentationUrl}
|
||||
>
|
||||
<Link
|
||||
underline={false}
|
||||
footer="minor"
|
||||
@@ -266,8 +271,8 @@ export const Footer = () => {
|
||||
{t('links.accessibility')}
|
||||
</Link>
|
||||
</StyledLi>
|
||||
{data?.diagnostics?.connection_test_enabled && (
|
||||
<StyledLi divider>
|
||||
{isConnectionTestEnabled && (
|
||||
<StyledLi divider={!!technicalDocumentationUrl}>
|
||||
<Link
|
||||
underline={false}
|
||||
footer="minor"
|
||||
@@ -278,19 +283,21 @@ export const Footer = () => {
|
||||
</Link>
|
||||
</StyledLi>
|
||||
)}
|
||||
<StyledLi>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="minor"
|
||||
href="https://docs.numerique.gouv.fr/docs/f2baa1b9-f29e-4d58-959d-65d4376fc6b8/"
|
||||
aria-label={
|
||||
t('links.technicalDetails') + ' - ' + t('links.ariaLabel')
|
||||
}
|
||||
>
|
||||
{t('links.technicalDetails')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
{technicalDocumentationUrl && (
|
||||
<StyledLi>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="minor"
|
||||
href={technicalDocumentationUrl}
|
||||
aria-label={
|
||||
t('links.technicalDetails') + ' - ' + t('links.ariaLabel')
|
||||
}
|
||||
>
|
||||
{t('links.technicalDetails')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
)}
|
||||
</SecondRow>
|
||||
<ThirdRow>
|
||||
{t('mentions')}{' '}
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
"pending": "Ausstehend",
|
||||
"running": "Läuft…",
|
||||
"success": "Erfolgreich",
|
||||
"warning": "Nicht optimal",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"skipped": "Übersprungen"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "erfolgreich",
|
||||
"warnings": "nicht optimal",
|
||||
"failed": "fehlgeschlagen",
|
||||
"skipped": "übersprungen"
|
||||
},
|
||||
@@ -46,6 +48,10 @@
|
||||
"passedHint": "Ihr Browser, Ihre Geräte und Ihr Netzwerk sind für eine Besprechung bereit.",
|
||||
"partial": "Teilweiser Test",
|
||||
"partialHint": "Einige Prüfungen wurden übersprungen. Erlauben Sie den Zugriff auf Ihre Kamera und Ihr Mikrofon, um diese zu testen.",
|
||||
"warning": "Verbindung nicht optimal",
|
||||
"warningHint": "Sie können an Ihren Besprechungen teilnehmen, aber die Bild- und Tonqualität kann aufgrund Ihrer Netzwerkeinstellungen beeinträchtigt sein. Ihre IT-Abteilung kann hier Abhilfe schaffen.",
|
||||
"warningDocLink": "Netzwerkanforderungen für Ihre IT-Abteilung",
|
||||
"warningDocLinkAriaLabel": "Netzwerkanforderungen für Ihre IT-Abteilung öffnen – öffnet in neuem Tab",
|
||||
"failed_one": "{{count}} Prüfung fehlgeschlagen",
|
||||
"failed_other": "{{count}} Prüfungen fehlgeschlagen",
|
||||
"failedHint": "Öffnen Sie die fehlgeschlagenen Prüfungen für weitere Details und senden Sie den Bericht an Ihre IT-Abteilung."
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
"pending": "Pending",
|
||||
"running": "Running…",
|
||||
"success": "Passed",
|
||||
"warning": "Not optimal",
|
||||
"failed": "Failed",
|
||||
"skipped": "Skipped"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "passed",
|
||||
"warnings": "not optimal",
|
||||
"failed": "failed",
|
||||
"skipped": "skipped"
|
||||
},
|
||||
@@ -46,6 +48,10 @@
|
||||
"passedHint": "Your browser, your devices and your network are ready for a meeting.",
|
||||
"partial": "Partially tested",
|
||||
"partialHint": "Some checks were skipped. Allow access to your camera and microphone to test them.",
|
||||
"warning": "Suboptimal connection",
|
||||
"warningHint": "You can join your meetings, but video and audio quality may be reduced because of your network settings. Your IT department can improve this.",
|
||||
"warningDocLink": "Network requirements for your IT department",
|
||||
"warningDocLinkAriaLabel": "Open the network requirements for your IT department - opens in new window",
|
||||
"failed_one": "{{count}} check failed",
|
||||
"failed_other": "{{count}} checks failed",
|
||||
"failedHint": "Open the failed checks below for details, then send the report to your IT department."
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
"pending": "En espera",
|
||||
"running": "En curso…",
|
||||
"success": "Correcto",
|
||||
"warning": "No óptimo",
|
||||
"failed": "Error",
|
||||
"skipped": "Omitido"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "correctas",
|
||||
"warnings": "no óptimas",
|
||||
"failed": "con errores",
|
||||
"skipped": "omitidas"
|
||||
},
|
||||
@@ -46,6 +48,10 @@
|
||||
"passedHint": "Tu navegador, tus dispositivos y tu red están listos para una reunión.",
|
||||
"partial": "Prueba parcial",
|
||||
"partialHint": "Se han omitido algunas comprobaciones. Autoriza el acceso a tu cámara y a tu micrófono para probarlos.",
|
||||
"warning": "Conexión no óptima",
|
||||
"warningHint": "Puedes participar en tus reuniones, pero la calidad de la imagen y del sonido puede verse reducida por la configuración de tu red. Tu servicio informático puede mejorar la situación.",
|
||||
"warningDocLink": "Requisitos de red para tu servicio informático",
|
||||
"warningDocLinkAriaLabel": "Abrir los requisitos de red para tu servicio informático - se abre en una nueva ventana",
|
||||
"failed_one": "{{count}} verificación en error",
|
||||
"failed_other": "{{count}} verificaciones en error",
|
||||
"failedHint": "Abre las verificaciones en error para ver el detalle y transmite después el informe a tu servicio informático."
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
"pending": "En attente",
|
||||
"running": "En cours…",
|
||||
"success": "Réussi",
|
||||
"warning": "Non optimal",
|
||||
"failed": "Échec",
|
||||
"skipped": "Ignoré"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "réussis",
|
||||
"warnings": "non optimaux",
|
||||
"failed": "en échec",
|
||||
"skipped": "ignorés"
|
||||
},
|
||||
@@ -46,6 +48,10 @@
|
||||
"passedHint": "Votre navigateur, vos périphériques et votre réseau sont prêts pour une réunion.",
|
||||
"partial": "Test partiel",
|
||||
"partialHint": "Certaines vérifications ont été ignorées. Autorisez l'accès à votre caméra et à votre microphone pour les tester.",
|
||||
"warning": "Connexion non optimale",
|
||||
"warningHint": "Vous pouvez participer à vos réunions, mais la qualité de l'image et du son risque d'être réduite à cause des réglages de votre réseau. Votre service informatique peut améliorer la situation.",
|
||||
"warningDocLink": "Prérequis réseau à transmettre à votre service informatique",
|
||||
"warningDocLinkAriaLabel": "Ouvrir les prérequis réseau à transmettre à votre service informatique - ouvre dans une nouvelle fenêtre",
|
||||
"failed_one": "{{count}} vérification en échec",
|
||||
"failed_other": "{{count}} vérifications en échec",
|
||||
"failedHint": "Ouvrez les vérifications en échec pour voir le détail, puis transmettez le rapport à votre service informatique."
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
"pending": "In afwachting",
|
||||
"running": "Bezig…",
|
||||
"success": "Geslaagd",
|
||||
"warning": "Niet optimaal",
|
||||
"failed": "Mislukt",
|
||||
"skipped": "Overgeslagen"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "geslaagd",
|
||||
"warnings": "niet optimaal",
|
||||
"failed": "mislukt",
|
||||
"skipped": "overgeslagen"
|
||||
},
|
||||
@@ -46,6 +48,10 @@
|
||||
"passedHint": "Je browser, apparaten en netwerk zijn klaar voor een vergadering.",
|
||||
"partial": "Gedeeltelijke test",
|
||||
"partialHint": "Sommige controles zijn overgeslagen. Geef toegang tot je camera en microfoon om deze te testen.",
|
||||
"warning": "Verbinding niet optimaal",
|
||||
"warningHint": "Je kunt deelnemen aan je vergaderingen, maar de beeld- en geluidskwaliteit kan minder zijn door de instellingen van je netwerk. Je IT-afdeling kan dit verbeteren.",
|
||||
"warningDocLink": "Netwerkvereisten voor je IT-afdeling",
|
||||
"warningDocLinkAriaLabel": "Netwerkvereisten voor je IT-afdeling openen - opent in nieuw venster",
|
||||
"failed_one": "{{count}} controle mislukt",
|
||||
"failed_other": "{{count}} controles mislukt",
|
||||
"failedHint": "Open de mislukte controles voor meer details en stuur het rapport door naar je IT-afdeling."
|
||||
|
||||
@@ -103,3 +103,8 @@ html:has(.lk-video-conference) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Same workaround as above, see adobe/react-spectrum#10680 */
|
||||
[role='tooltip'][data-rac]:not([data-placement]) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -6,6 +6,7 @@ declare const __MEDIAPIPE_VERSION__: string
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_MEDIA_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Kept apart from vite.config.ts so the tests load none of the build plugins.
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
})
|
||||
@@ -157,6 +157,7 @@ backend:
|
||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
|
||||
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
|
||||
FRONTEND_DOCUMENTATION_URL: "https://docs.numerique.gouv.fr/docs/7c5bd65d-3c21-486f-bce1-26e0a921d642/"
|
||||
FRONTEND_TECHNICAL_DOCUMENTATION_URL: "https://docs.numerique.gouv.fr/docs/f2baa1b9-f29e-4d58-959d-65d4376fc6b8/"
|
||||
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
|
||||
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
|
||||
FRONTEND_TRANSCRIPTION_DESTINATION: "https://docs.numerique.gouv.fr"
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.agentMetadata.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.agentSubtitles.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -41,6 +41,10 @@ items:
|
||||
imagePullPolicy: {{ ($.Values.backend.image | default dict).pullPolicy | default $.Values.image.pullPolicy }}
|
||||
args:
|
||||
{{- toYaml .command | nindent 22 }}
|
||||
{{- with $.Values.backend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 20 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars}}
|
||||
{{- $envVars | indent 22 }}
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -49,6 +49,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -49,6 +49,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{{- $envVars := include "meet.env.transformDict" (mergeOverwrite (default dict .Values.backend.envVars) (default dict .Values.celeryBackend.envVars)) -}}
|
||||
{{- $envFrom := concat (default (list) .Values.backend.envFrom) (default (list) .Values.celeryBackend.envFrom) -}}
|
||||
{{- $fullName := include "meet.celeryBackend.fullname" . -}}
|
||||
{{- $component := "celery-backend" -}}
|
||||
apiVersion: apps/v1
|
||||
@@ -50,6 +51,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.celerySummarize.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -46,6 +46,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.celerySummaryBackend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{{- $sharedEnvVars := default (dict) .Values.celeryTranscribe.envVars -}}
|
||||
{{- $sharedEnvFrom := default (list) .Values.celeryTranscribe.envFrom -}}
|
||||
{{- $component := "celery-transcribe" -}}
|
||||
|
||||
{{- range $idx, $instance := .Values.celeryTranscribe.instances }}
|
||||
@@ -6,6 +7,8 @@
|
||||
{{- $fullName := printf "%s-%s" (include "meet.celeryTranscribe.fullname" $) $instance.name }}
|
||||
{{- $extraInstanceEnvVars := default (dict) $instance.extraEnvVars -}}
|
||||
{{- $mergedInstanceEnvVars := merge $extraInstanceEnvVars $sharedEnvVars -}}
|
||||
{{- $extraInstanceEnvFrom := default (list) $instance.extraEnvFrom -}}
|
||||
{{- $envFrom := concat $sharedEnvFrom $extraInstanceEnvFrom -}}
|
||||
{{- $fakeInstanceObjectForEnvHelper := dict "envVars" $mergedInstanceEnvVars -}}
|
||||
{{- $envVars := include "meet.common.env" (list . $fakeInstanceObjectForEnvHelper) -}}
|
||||
apiVersion: apps/v1
|
||||
@@ -60,6 +63,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.frontend.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -50,6 +50,10 @@ spec:
|
||||
args:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.summary.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- if $envVars }}
|
||||
{{- $envVars | indent 12 }}
|
||||
|
||||
@@ -232,6 +232,9 @@ backend:
|
||||
envVars:
|
||||
<<: *commonEnvVars
|
||||
|
||||
## @param backend.envFrom Import environment variables from ConfigMaps or Secrets
|
||||
envFrom: []
|
||||
|
||||
## @param backend.podAnnotations Annotations to add to the backend Pod
|
||||
podAnnotations: {}
|
||||
|
||||
@@ -433,6 +436,9 @@ frontend:
|
||||
envVars:
|
||||
<<: *commonEnvVars
|
||||
|
||||
## @param frontend.envFrom Import environment variables from ConfigMaps or Secrets
|
||||
envFrom: []
|
||||
|
||||
## @param frontend.podAnnotations Annotations to add to the frontend Pod
|
||||
podAnnotations: {}
|
||||
|
||||
@@ -624,6 +630,9 @@ summary:
|
||||
envVars:
|
||||
<<: *commonEnvVars
|
||||
|
||||
## @param summary.envFrom Import environment variables from ConfigMaps or Secrets
|
||||
envFrom: []
|
||||
|
||||
## @param summary.podAnnotations Annotations to add to the summary Pod
|
||||
podAnnotations: {}
|
||||
|
||||
@@ -751,6 +760,9 @@ celeryBackend:
|
||||
envVars:
|
||||
<<: *commonEnvVars
|
||||
|
||||
## @param celeryBackend.envFrom Import additional environment variables from ConfigMaps or Secrets
|
||||
envFrom: []
|
||||
|
||||
## @param celeryBackend.podAnnotations Annotations to add to the celeryBackend Pod
|
||||
podAnnotations: {}
|
||||
|
||||
@@ -863,6 +875,9 @@ celeryTranscribe:
|
||||
envVars:
|
||||
<<: *commonEnvVars
|
||||
|
||||
## @param celeryTranscribe.envFrom Import environment variables from ConfigMaps or Secrets
|
||||
envFrom: []
|
||||
|
||||
## @param celeryTranscribe.podAnnotations Annotations to add to the celeryTranscribe Pod
|
||||
podAnnotations: {}
|
||||
|
||||
@@ -923,6 +938,7 @@ celeryTranscribe:
|
||||
## @extra celeryTranscribe.instances[].name Unique name suffix for the instance (used in the Deployment name and pod labels)
|
||||
## @extra celeryTranscribe.instances[].replicas Override the number of replicas for this specific instance
|
||||
## @extra celeryTranscribe.instances[].extraEnvVars Additional environment variables for this specific instance (same structure as envVars)
|
||||
## @extra celeryTranscribe.instances[].extraEnvFrom Additional ConfigMap or Secret environment sources for this specific instance
|
||||
## @extra celeryTranscribe.instances[].command Override the container command for this specific instance
|
||||
## @extra celeryTranscribe.instances[].args Override the container args for this specific instance
|
||||
## @extra celeryTranscribe.instances[].resources Override resource requirements for this specific instance
|
||||
@@ -933,6 +949,7 @@ celeryTranscribe:
|
||||
instances:
|
||||
- name: default
|
||||
extraEnvVars: {}
|
||||
extraEnvFrom: []
|
||||
|
||||
## @section celerySummarize
|
||||
|
||||
@@ -990,6 +1007,9 @@ celerySummarize:
|
||||
envVars:
|
||||
<<: *commonEnvVars
|
||||
|
||||
## @param celerySummarize.envFrom Import environment variables from ConfigMaps or Secrets
|
||||
envFrom: []
|
||||
|
||||
## @param celerySummarize.podAnnotations Annotations to add to the celerySummarize Pod
|
||||
podAnnotations: {}
|
||||
|
||||
@@ -1099,6 +1119,9 @@ celerySummaryBackend:
|
||||
envVars:
|
||||
<<: *commonEnvVars
|
||||
|
||||
## @param celerySummaryBackend.envFrom Import environment variables from ConfigMaps or Secrets
|
||||
envFrom: []
|
||||
|
||||
## @param celerySummaryBackend.podAnnotations Annotations to add to the celerySummaryBackend Pod
|
||||
podAnnotations: {}
|
||||
|
||||
@@ -1206,6 +1229,9 @@ agentMetadata:
|
||||
envVars:
|
||||
<<: *commonEnvVars
|
||||
|
||||
## @param agentMetadata.envFrom Import environment variables from ConfigMaps or Secrets
|
||||
envFrom: []
|
||||
|
||||
## @param agentMetadata.podAnnotations Annotations to add to the agentMetadata Pod
|
||||
podAnnotations: {}
|
||||
|
||||
@@ -1297,6 +1323,9 @@ agentSubtitles:
|
||||
# are NOT supported by the LiveKit Deepgram plugin in streaming mode.
|
||||
# Use language="multi" for automatic multilingual support (10 languages).
|
||||
|
||||
## @param agentSubtitles.envFrom Import environment variables from ConfigMaps or Secrets
|
||||
envFrom: []
|
||||
|
||||
## @param agentSubtitles.podAnnotations Annotations to add to the agentSubtitles Pod
|
||||
podAnnotations: {}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
#!/bin/sh
|
||||
set -e
|
||||
# Run html-to-text to convert all html files to text files
|
||||
DIR_MAILS="../backend/core/templates/mail/"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/sh
|
||||
|
||||
# Run mjml command to convert all mjml templates to html files
|
||||
DIR_MAILS="../backend/core/templates/mail/html/"
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build-mjml-to-html": "bash ./bin/mjml-to-html",
|
||||
"build-html-to-plain-text": "bash ./bin/html-to-plain-text",
|
||||
"build-mjml-to-html": "sh ./bin/mjml-to-html",
|
||||
"build-html-to-plain-text": "sh ./bin/html-to-plain-text",
|
||||
"build": "npm run build-mjml-to-html && npm run build-html-to-plain-text"
|
||||
},
|
||||
"volta": {
|
||||
|
||||
@@ -9,7 +9,6 @@ from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
import sentry_sdk
|
||||
from celery import Celery, signals
|
||||
from celery.utils.log import get_task_logger
|
||||
from openai.types.audio import Transcription
|
||||
@@ -42,6 +41,7 @@ from summary.core.prompt import (
|
||||
PROMPT_SYSTEM_TLDR,
|
||||
PROMPT_USER_PART,
|
||||
)
|
||||
from summary.core.sentry import init_sentry
|
||||
from summary.core.shared_models import (
|
||||
SummarizeWebhookFailurePayload,
|
||||
SummarizeWebhookSuccessPayload,
|
||||
@@ -75,12 +75,11 @@ celery = Celery(
|
||||
|
||||
celery.config_from_object("summary.core.celery_config")
|
||||
|
||||
if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||
|
||||
@signals.celeryd_init.connect
|
||||
def init_sentry(**_kwargs):
|
||||
"""Initialize sentry."""
|
||||
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
|
||||
@signals.celeryd_init.connect
|
||||
def init_celery_sentry(**_kwargs):
|
||||
"""Initialize Sentry in the Celery worker."""
|
||||
init_sentry()
|
||||
|
||||
|
||||
file_service = FileService()
|
||||
|
||||
@@ -84,6 +84,8 @@ class Settings(BaseSettings):
|
||||
aws_s3_secret_access_key: SecretStr
|
||||
aws_s3_secure_access: bool = True
|
||||
aws_s3_region_name: str | None = None
|
||||
aws_s3_request_checksum_calculation: str | None = None
|
||||
aws_s3_response_checksum_validation: str | None = None
|
||||
aws_transcript_path: str = "transcripts"
|
||||
aws_summary_path: str = "summaries"
|
||||
|
||||
@@ -126,6 +128,7 @@ class Settings(BaseSettings):
|
||||
# Sentry
|
||||
sentry_is_enabled: bool = False
|
||||
sentry_dsn: Optional[str] = None
|
||||
sentry_traces_sample_rate: float = Field(default=0.1, ge=0.0, le=1.0)
|
||||
|
||||
# Posthog (analytics)
|
||||
posthog_enabled: bool = False
|
||||
|
||||
@@ -286,7 +286,12 @@ def _build_s3_client():
|
||||
aws_access_key_id=settings.aws_s3_access_key_id,
|
||||
aws_secret_access_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||
region_name=settings.aws_s3_region_name,
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
||||
config=Config(
|
||||
signature_version="s3v4",
|
||||
s3={"addressing_style": "path"},
|
||||
request_checksum_calculation=settings.aws_s3_request_checksum_calculation,
|
||||
response_checksum_validation=settings.aws_s3_response_checksum_validation,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Sentry configuration."""
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.scrubber import DEFAULT_DENYLIST, DEFAULT_PII_DENYLIST, EventScrubber
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
# Exact names (case-insensitive) of variables and dict keys to redact.
|
||||
SENSITIVE_DATA_DENYLIST = [
|
||||
# Raw payloads and serialized bodies
|
||||
"data",
|
||||
"body",
|
||||
"payload",
|
||||
"args",
|
||||
"kwargs",
|
||||
"response",
|
||||
"res",
|
||||
# Transcripts
|
||||
"transcript",
|
||||
"transcription",
|
||||
"transcription_json",
|
||||
"transcription_res",
|
||||
"new_transcription",
|
||||
"segments",
|
||||
"word_segments",
|
||||
"words",
|
||||
"text",
|
||||
"content",
|
||||
"formatted_output",
|
||||
# Summaries and LLM exchanges
|
||||
"summary",
|
||||
"raw_summary",
|
||||
"cleaned_summary",
|
||||
"tldr",
|
||||
"part",
|
||||
"parts",
|
||||
"parts_summarized",
|
||||
"next_steps",
|
||||
"title",
|
||||
"titles",
|
||||
"action",
|
||||
"line",
|
||||
"lines",
|
||||
"user_prompt",
|
||||
"prompt_user_part",
|
||||
"messages",
|
||||
"json_data", # OpenAI client internals
|
||||
"opts",
|
||||
"options",
|
||||
"input_options",
|
||||
# Participants' personal data
|
||||
"email",
|
||||
"user_email",
|
||||
"assignees",
|
||||
"participant_name",
|
||||
"participant_names",
|
||||
"participants_info",
|
||||
"speaker_to_name",
|
||||
# Signed / pre-authenticated URLs
|
||||
"cloud_storage_url",
|
||||
"transcription_data_url",
|
||||
"summary_data_url",
|
||||
]
|
||||
|
||||
|
||||
def build_event_scrubber() -> EventScrubber:
|
||||
"""Build the scrubber redacting meeting content and personal data."""
|
||||
return EventScrubber(
|
||||
denylist=DEFAULT_DENYLIST + SENSITIVE_DATA_DENYLIST,
|
||||
pii_denylist=DEFAULT_PII_DENYLIST,
|
||||
recursive=True,
|
||||
)
|
||||
|
||||
|
||||
def init_sentry() -> None:
|
||||
"""Initialize Sentry if enabled in the settings."""
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.sentry_is_enabled:
|
||||
return
|
||||
|
||||
if not settings.sentry_dsn:
|
||||
return
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=settings.sentry_dsn,
|
||||
traces_sample_rate=settings.sentry_traces_sample_rate,
|
||||
# Never attach request bodies, Celery task arguments or user data.
|
||||
send_default_pii=False,
|
||||
# Task creation requests carry the content to summarize.
|
||||
max_request_body_size="never",
|
||||
include_local_variables=True,
|
||||
event_scrubber=build_event_scrubber(),
|
||||
)
|
||||
@@ -1,18 +1,17 @@
|
||||
"""Application."""
|
||||
|
||||
import sentry_sdk
|
||||
from dockerflow.fastapi import router as dockerflow_router
|
||||
from fastapi import FastAPI
|
||||
|
||||
from summary.api.main import api_router_v2
|
||||
from summary.core import checks # noqa: F401 -- registers the Dockerflow checks
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.sentry import init_sentry
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
|
||||
init_sentry()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Tests for the Sentry configuration.
|
||||
|
||||
Each test raises an error from code handling meeting content and inspects the
|
||||
event Sentry would send: the content must be redacted, while harmless local
|
||||
variables are kept for debugging.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Iterator
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
import pytest
|
||||
import sentry_sdk
|
||||
from botocore.exceptions import ClientError
|
||||
from botocore.stub import Stubber
|
||||
from sentry_sdk.transport import Transport
|
||||
|
||||
from summary.core import file_service
|
||||
from summary.core import sentry as sentry_module
|
||||
from summary.core.file_service import FileService
|
||||
from summary.core.llm_service import LLMException, LLMService
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
|
||||
CANARY = "CANARY-MEETING-CONTENT"
|
||||
|
||||
SentryEvents = Callable[[], list[str]]
|
||||
|
||||
|
||||
class _CapturingTransport(Transport):
|
||||
"""Keep serialized events in memory instead of sending them."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.events: list[str] = []
|
||||
|
||||
def capture_envelope(self, envelope):
|
||||
"""Store each serialized event of the envelope."""
|
||||
for item in envelope.items:
|
||||
if item.type == "event":
|
||||
self.events.append(item.payload.get_bytes().decode())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sentry_events() -> Iterator[SentryEvents]:
|
||||
"""Initialize Sentry as in production, with an in-memory transport."""
|
||||
transport = _CapturingTransport()
|
||||
sentry_sdk.init(
|
||||
dsn="https://public@sentry.example.com/1",
|
||||
transport=transport,
|
||||
send_default_pii=False,
|
||||
include_local_variables=True,
|
||||
event_scrubber=sentry_module.build_event_scrubber(),
|
||||
default_integrations=False,
|
||||
)
|
||||
|
||||
def flush() -> list[str]:
|
||||
sentry_sdk.flush()
|
||||
return transport.events
|
||||
|
||||
yield flush
|
||||
sentry_sdk.init() # Disable Sentry for the following tests
|
||||
|
||||
|
||||
def _transcript() -> WhisperXResponse:
|
||||
return WhisperXResponse.model_validate(
|
||||
{
|
||||
"segments": [
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 1.0,
|
||||
"text": f"I don't know {CANARY}",
|
||||
"speaker": "SPEAKER_01",
|
||||
"words": [
|
||||
{
|
||||
"word": CANARY,
|
||||
"start": 0.0,
|
||||
"end": 1.0,
|
||||
"score": 0.9,
|
||||
"speaker": "SPEAKER_01",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _local_vars(event: str) -> list[dict]:
|
||||
"""Return the local variables of every frame of the event."""
|
||||
return [
|
||||
frame.get("vars", {})
|
||||
for exception in json.loads(event)["exception"]["values"]
|
||||
for frame in exception["stacktrace"]["frames"]
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_stubber(monkeypatch: pytest.MonkeyPatch) -> Iterator[Stubber]:
|
||||
"""Stub the S3 client built by the file service."""
|
||||
monkeypatch.setattr(
|
||||
file_service,
|
||||
"settings",
|
||||
file_service.settings.model_copy(
|
||||
update={
|
||||
"aws_s3_endpoint_url": "garage:9000",
|
||||
"aws_s3_secure_access": False,
|
||||
"aws_s3_region_name": "fr-par",
|
||||
"aws_storage_bucket_name": "meet-media-storage",
|
||||
}
|
||||
),
|
||||
)
|
||||
stubber = Stubber(file_service._build_s3_client())
|
||||
stubber.activate()
|
||||
monkeypatch.setattr(file_service, "_build_s3_client", lambda: stubber.client)
|
||||
yield stubber
|
||||
stubber.assert_no_pending_responses()
|
||||
|
||||
|
||||
def test_sentry_store_transcript_failure_redacts_transcript(
|
||||
sentry_events: SentryEvents, s3_stubber: Stubber
|
||||
) -> None:
|
||||
"""A failed S3 upload does not send the transcript, but keeps the job id."""
|
||||
s3_stubber.add_client_error("put_object", service_error_code="InvalidDigest")
|
||||
|
||||
try:
|
||||
FileService().store_transcript(transcript=_transcript(), job_id="job-1")
|
||||
except ClientError:
|
||||
sentry_sdk.capture_exception()
|
||||
else:
|
||||
pytest.fail("store_transcript should have failed")
|
||||
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
|
||||
store_transcript_vars = next(
|
||||
frame_vars
|
||||
for frame_vars in _local_vars(event)
|
||||
if "transcript_path" in frame_vars
|
||||
)
|
||||
assert store_transcript_vars["job_id"] == "'job-1'"
|
||||
assert store_transcript_vars["transcript_path"] == "'transcripts/job-1.json'"
|
||||
assert store_transcript_vars["data"] == "[Filtered]"
|
||||
assert store_transcript_vars["transcript"] == "[Filtered]"
|
||||
|
||||
|
||||
def test_sentry_llm_failure_redacts_prompts(sentry_events: SentryEvents) -> None:
|
||||
"""A failed LLM call does not send the prompts, even from OpenAI internals."""
|
||||
client = openai.OpenAI(
|
||||
api_key="test-key",
|
||||
base_url="https://llm.example.com/v1",
|
||||
max_retries=0,
|
||||
http_client=httpx.Client(
|
||||
transport=httpx.MockTransport(lambda request: httpx.Response(500))
|
||||
),
|
||||
)
|
||||
observability = Mock(is_enabled=False)
|
||||
observability.get_openai_client.return_value = client
|
||||
|
||||
try:
|
||||
LLMService(observability).call(
|
||||
system_prompt="Summarize this meeting.",
|
||||
user_prompt=f"Transcript: {CANARY}",
|
||||
name="tldr",
|
||||
)
|
||||
except LLMException:
|
||||
sentry_sdk.capture_exception()
|
||||
else:
|
||||
pytest.fail("the LLM call should have failed")
|
||||
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
|
||||
|
||||
def test_init_sentry_uses_the_event_scrubber(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Sentry is initialized with local variables and the content scrubber."""
|
||||
settings = sentry_module.get_settings().model_copy(
|
||||
update={"sentry_is_enabled": True, "sentry_dsn": "https://k@example.com/1"}
|
||||
)
|
||||
monkeypatch.setattr(sentry_module, "get_settings", lambda: settings)
|
||||
init = Mock()
|
||||
monkeypatch.setattr(sentry_module.sentry_sdk, "init", init)
|
||||
|
||||
sentry_module.init_sentry()
|
||||
|
||||
init.assert_called_once()
|
||||
kwargs = init.call_args.kwargs
|
||||
assert kwargs["send_default_pii"] is False
|
||||
assert kwargs["max_request_body_size"] == "never"
|
||||
assert kwargs["include_local_variables"] is True
|
||||
denylist = kwargs["event_scrubber"].denylist
|
||||
assert {"data", "transcript", "content", "summary", "password"} <= set(denylist)
|
||||
|
||||
|
||||
def test_init_sentry_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Sentry is not initialized when disabled."""
|
||||
settings = sentry_module.get_settings().model_copy(
|
||||
update={"sentry_is_enabled": False, "sentry_dsn": "https://k@example.com/1"}
|
||||
)
|
||||
monkeypatch.setattr(sentry_module, "get_settings", lambda: settings)
|
||||
init = Mock()
|
||||
monkeypatch.setattr(sentry_module.sentry_sdk, "init", init)
|
||||
|
||||
sentry_module.init_sentry()
|
||||
|
||||
init.assert_not_called()
|
||||
Reference in New Issue
Block a user