Compare commits

..

14 Commits

Author SHA1 Message Date
leo b86ac35dc6 wip 2026-10-05 18:18:20 +02:00
leo f182474ed9 wip 2026-10-05 16:23:49 +02:00
davd-gzl c075db25a4 🐛(frontend) honour Keep hand raised when picture-in-picture is open
The hand button owns the lower-hand timer and the toast, and the
picture-in-picture window draws a second copy of that button, so two
offers go up and dismissing one leaves the other to lower the hand.
Move the watching into a component rendered once beside the room.
2026-10-04 23:21:31 +02:00
Rishi Gupta c0dfd88776 ♿️(frontend) expose loading state to assistive technology
Surface the page's loading state to assistive technology, so screen
readers can announce that the page is still loading instead of
reading a partially rendered state as if it were complete.
2026-10-04 22:58:21 +02:00
Rishi Gupta 94d2b80c17 ✏️(docs) fix README wording and typos
Address review feedback from #1 by fixing wording issues and typos
across the README.
2026-10-04 22:58:21 +02:00
KusalPabasara bd2dfcae80 ✨(helm) add envFrom support to chart
Allow every chart workload to import environment variables from ConfigMaps.

Keep existing empty defaults for backward compatibility.
2026-10-04 22:34:31 +02:00
lebaudantoine 0b4a83c92e 🐛(brevo) use django-lasuite for marketing management
When the user is updated, their lists on Brevo are overwritten with
the new value: this removes lists set by other products.

Switch to the common lib implementation from `django-lasuite`,
which manages this correctly.

This change was initially proposed by @qbey, but at the time our
deployment did not have a Celery worker running alongside the
backend. Since then, a Celery worker has been deployed, so the
switch to the common lib approach is now safe to adopt.
2026-10-04 22:10:16 +02:00
lebaudantoine 9187173cae ✨(frontend) warn users when the connection falls back to TURN
Highlight in the connection test when the user is connecting
through a TURN relay, especially over TLS or TCP. This usually
indicates that some network configuration is required on their
side, and gives them a concrete signal to pass to their IT team.

Suggested by a technical user, this is a first step toward making
users more autonomous when troubleshooting access to the tool.

Follow-up: show a similar warning in-product when we detect a
mid-meeting fallback to TURN/TLS. A one-time hint for first-time
users would likely be enough.
2026-10-03 23:36:42 +02:00
kaelvar 364bbf4f0b ✨(frontend) let signed-out visitors start a meeting
The home page only offers meeting creation to authenticated users, while the
backend already serves ephemeral rooms to anonymous visitors when
ALLOW_UNREGISTERED_ROOMS is enabled (the flag is checked in the room retrieve
view, not in the create one).

Expose the flag in the frontend configuration and, when it is on, show the
existing "Create a meeting" button to signed-out visitors. It navigates to a
freshly generated room id rather than calling POST /rooms/, which stays
reserved for registered rooms and for authenticated users.

The invite dialog opens for such a creator when the room is unregistered
(null id) and the navigation carries `create`. The `mode` computed in Room
is left untouched, so permissions and the join screen behave as before.

The home buttons row now wraps: signed-out visitors can see three controls
(create, join, login), and at the xsm breakpoint the fixed-width ProConnect
button leaves too little room for the other two.
2026-10-02 19:06:02 +02:00
lebaudantoine a6a12ef586 🐛(frontend) hide tooltips until they have a computed placement
A React Aria overlay is rendered at `top: 0; left: 0` until
`useOverlayPosition` computes its position, and `data-placement` is
only set once that succeeds. When the pointer moves quickly between
triggers, a closing tooltip can mount for its exit animation without
ever being positioned, React Aria does not retry, so it stays
stuck in the top-left corner.

Hide tooltips until they have a `data-placement` set, so unpositioned
tooltips never flash in the corner. Use `visibility` rather than
`display: none`, so the element stays measurable for the positioning
pass.
2026-10-02 18:19:43 +02:00
snyk-bot 2622d89f63 ⬆️(frontend) upgrade react-aria dependencies
Snyk has created this PR to upgrade react-stately from 3.48.0 to 3.49.0.
I had to bump react-aria@3.51.0 react-aria-components@1.20.0
2026-10-02 18:19:43 +02:00
lebaudantoine f2d50770cf 🔧(summary) add setting to control Sentry traces sampling rate
Replace the deprecated `enable_tracing=True` with `traces_sample_rate`,
read from a new `sentry_traces_sample_rate` setting (default 0.1,
validated to the 0.0–1.0 range).

Previously, tracing sampled 100% of transactions, which is costly and
unnecessary in production. The rate can now be tuned per environment
without a code change.
2026-10-02 17:41:02 +02:00
lebaudantoine 11e8470aa5 🔒️(summary) redact meeting content from Sentry events
Sentry attaches stack frame locals to its events. When storing a
transcript in S3 failed, the full transcript held in `data` and
`transcript` was sent to Sentry.

Keep locals for debugging, but scrub variables and nested dict keys
known to hold transcripts, summaries, LLM prompts, participants'
personal data or pre-signed URLs. Share the Sentry init between the
API and the Celery worker, and never send request bodies.
2026-10-02 17:41:02 +02:00
lebaudantoine 3bf78f0f5b 🐛(summary) disable default S3 checksums for GCS-compatible storage
Since boto3/botocore 1.36, the S3 client computes CRC32 checksums on
uploads by default (request_checksum_calculation="when_supported").
PutObject requests are then sent with aws-chunked encoding, a trailing
x-amz-checksum-crc32 header and a STREAMING-UNSIGNED-PAYLOAD-TRAILER
content hash.

Our production storage (S3NS, storage.s3nsapis.fr) is built on Google
Cloud Storage and exposes it through GCS's S3-compatible XML API, which
does not support these flexible checksums. It rejects the request with
a 403 SignatureDoesNotMatch ("Invalid argument"), so storing transcripts
failed in the Celery worker. Garage, used locally, supports them, which
is why the issue only appeared in production after switching the
client to boto3.

Add aws_request_checksum_calculation and
aws_response_checksum_validation settings, defaulting to
"when_required", and pass them to the botocore Config of the summary S3
client and the backend S3 client. Checksums are then only sent for
operations that require them, restoring the pre-1.36 behavior.
2026-10-02 17:41:02 +02:00
51 changed files with 879 additions and 590 deletions
+13 -1
View File
@@ -10,9 +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
- ✨(backend) authenticate external API calls with Menshen exchanged tokens
### Changed
- ✨(frontend) warn users when the connection falls back to TURN
- 🔧(backend) configure the technical documentation url
### Fixed
@@ -23,6 +32,10 @@ and this project adheres to
- 🔒️(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
@@ -153,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
+3 -3
View File
@@ -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
+1
View File
@@ -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 |
+1
View File
@@ -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
),
+8 -25
View File
@@ -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."""
+166 -13
View File
@@ -4,21 +4,51 @@
# ruff: noqa: PLR0913
import logging
from dataclasses import asdict
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import requests
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from menshen_client import Configuration, IntrospectionRequest, TokenExchangeClient
from menshen_client.exceptions import ResponseParsingError
from rest_framework import authentication, exceptions
from core.models import Application
from core.models import Application, ApplicationScope
from core.services import jwt_token
User = get_user_model()
logger = logging.getLogger(__name__)
def get_bearer_token(request):
"""Extract the bearer token from the Authorization header.
Returns:
Token string, or None if the request carries no bearer token
Raises:
AuthenticationFailed: If the Authorization header is malformed
"""
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != b"bearer":
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
return auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
@@ -71,22 +101,12 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
if not self.is_enabled:
return None
auth_header = authentication.get_authorization_header(request).split()
token = get_bearer_token(request)
if not auth_header or auth_header[0].lower() != b"bearer":
if token is None:
# Defer to next authentication backend
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
token = auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
return self.authenticate_credentials(token)
def decode_jwt(self, token):
@@ -252,6 +272,139 @@ class AddonsJWTAuthentication(BaseJWTAuthentication):
)
# Menshen grants scopes following La Suite's "service:resource:action" convention.
# Map them to the scopes expected by the external API permissions.
MENSHEN_SCOPES_MAPPING = {
"meet:room:create": ApplicationScope.ROOMS_CREATE,
}
class MenshenAuthentication(authentication.BaseAuthentication):
"""Authentication for tokens exchanged through Menshen.
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another service
exchanges its user's access token for a token targeting Meet, then calls the external
API on behalf of that user. Tokens are validated by introspection (RFC 7662). Menshen
only reports a token as active when Meet is among its audiences.
"""
def __init__(self):
"""Initialize the Menshen client from Django settings."""
super().__init__()
self._client = None
if not settings.MENSHEN_ENABLED:
return
self._client = TokenExchangeClient(
config=Configuration(
client_id=settings.MENSHEN_CLIENT_ID,
client_secret=settings.MENSHEN_CLIENT_SECRET,
server_root_url=settings.MENSHEN_SERVER_URL,
)
)
def authenticate(self, request):
"""Introspect the bearer token with Menshen.
Returns:
Tuple of (user, payload) if the token is active, None otherwise
"""
if self._client is None:
return None
token = get_bearer_token(request)
if token is None:
return None
payload = self.introspect(token)
if not payload.get("active"):
# Not a Menshen token, or an expired or revoked one: defer to next
# authentication backend
return None
user = self.get_user(payload)
scopes = payload.get("scope") or ""
payload["scope"] = [
MENSHEN_SCOPES_MAPPING[scope]
for scope in scopes.split()
if scope in MENSHEN_SCOPES_MAPPING
]
return (user, payload)
def introspect(self, token):
"""Submit the token to Menshen's introspection endpoint.
Errors are reported as an inactive token, so a Menshen outage doesn't
prevent the next authentication backends from running.
Args:
token: Bearer token string
Returns:
Introspection response dict
"""
try:
response = self._client.introspect(IntrospectionRequest(token=token))
except (requests.RequestException, ResponseParsingError) as e:
logger.warning("Menshen introspection failed: %s", e)
return {"active": False}
# Permission classes expect a dict payload in request.auth
return asdict(response)
def get_user(self, payload):
"""Retrieve or create the user from the introspection response.
Menshen forwards the `sub` and `email` of the subject token, as introspected
with the OIDC provider.
Args:
payload: Introspection response dict
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
sub = payload.get("sub")
if not sub:
logger.warning("Missing 'sub' in Menshen introspection response")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist as e:
if not settings.OIDC_CREATE_USER:
logger.warning("User not found: %s", sub)
raise exceptions.AuthenticationFailed("User not found.") from e
user = User(sub=sub, email=payload.get("email"))
user.set_unusable_password()
user.save()
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise exceptions.AuthenticationFailed("User account is disabled.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval."""
@@ -166,6 +166,7 @@ class RoomViewSet(
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
authentication.MenshenAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
-138
View File
@@ -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
@@ -0,0 +1,209 @@
"""Tests for the external API MenshenAuthentication."""
from unittest import mock
import pytest
import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import UserFactory
from core.models import RoleChoices, Room, User
pytestmark = pytest.mark.django_db
SERVER_URL = "https://menshen.example.com"
INTROSPECTION_ENDPOINT = f"{SERVER_URL}/auth/token/introspect/"
TOKEN = "menshen-exchanged-token"
@pytest.fixture(autouse=True)
def menshen_settings(settings):
"""Enable Menshen authentication."""
settings.MENSHEN_ENABLED = True
settings.MENSHEN_SERVER_URL = SERVER_URL
settings.MENSHEN_CLIENT_ID = "meet"
settings.MENSHEN_CLIENT_SECRET = "meet-secret"
def _introspection(sub, scope="meet:room:create", **overrides):
return {
"active": True,
"sub": sub,
"email": "user@example.com",
"scope": scope,
"aud": "meet",
"client_id": "menshen",
**overrides,
}
def _create_room():
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {TOKEN}")
return client.post("/external-api/v1.0/rooms/", {}, format="json")
@responses.activate
def test_menshen_active_token():
"""An active token with a mapped scope should create a room owned by the user."""
user = UserFactory()
# Catch and mock external HTTP requests
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub),
match=[responses.matchers.urlencoded_params_matcher({"token": TOKEN})],
)
response = _create_room()
assert response.status_code == 201
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert responses.calls[0].request.headers["Authorization"].startswith("Basic ")
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_disabled(mock_rs_authenticate, settings):
"""Menshen should not be called when disabled."""
settings.MENSHEN_ENABLED = False
response = _create_room()
assert response.status_code == 401
assert len(responses.calls) == 0
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_inactive_token_defers(mock_rs_authenticate):
"""An inactive token should defer to the next authentication backend."""
user = UserFactory()
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub, scope="meet:room:create", active=False),
)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_error_defers(mock_rs_authenticate):
"""A Menshen error should defer to the next authentication backend."""
responses.add(responses.POST, INTROSPECTION_ENDPOINT, status=500)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_unparsable_response_defers(mock_rs_authenticate):
"""A response the client can't parse should defer to the next backend."""
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json={"active": True, "unexpected": "field"},
)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
def test_menshen_unmapped_scope():
"""Scopes granted for other services or other Meet actions should not grant access."""
user = UserFactory()
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub, scope="drive:item:create meet:room:list"),
)
response = _create_room()
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
assert not Room.objects.exists()
@responses.activate
def test_menshen_missing_sub():
"""An active token without sub should be rejected."""
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(None))
response = _create_room()
assert response.status_code == 401
assert "invalid token claims." in str(response.data).lower()
@responses.activate
def test_menshen_inactive_user():
"""An active token for an inactive user should be rejected."""
user = UserFactory(is_active=False)
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(user.sub))
response = _create_room()
assert response.status_code == 401
assert "user account is disabled." in str(response.data).lower()
@responses.activate
def test_menshen_unknown_user_created(settings):
"""An unknown sub should create a user when OIDC_CREATE_USER is set."""
settings.OIDC_CREATE_USER = True
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
assert response.status_code == 201
user = User.objects.get(sub="new-sub")
assert user.email == "user@example.com"
assert user.is_active is True
@responses.activate
def test_menshen_unknown_user_not_created(settings):
"""An unknown sub should be rejected when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
assert response.status_code == 401
assert "user not found." in str(response.data).lower()
assert not User.objects.filter(sub="new-sub").exists()
+31 -18
View File
@@ -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
),
@@ -697,6 +702,20 @@ class Base(Configuration):
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
)
# Menshen, La Suite's OAuth 2.0 token exchange server
MENSHEN_ENABLED = values.BooleanValue(
False, environ_name="MENSHEN_ENABLED", environ_prefix=None
)
MENSHEN_SERVER_URL = values.Value(
None, environ_name="MENSHEN_SERVER_URL", environ_prefix=None
)
MENSHEN_CLIENT_ID = values.Value(
None, environ_name="MENSHEN_CLIENT_ID", environ_prefix=None
)
MENSHEN_CLIENT_SECRET = SecretFileValue(
None, environ_name="MENSHEN_CLIENT_SECRET", environ_prefix=None
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
@@ -933,24 +952,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(
+1
View File
@@ -49,6 +49,7 @@ dependencies = [
"gunicorn==26.2.0",
"jsonschema==4.26.0",
"markdown==3.10.3",
"menshen-client==0.1.0",
"nested-multipart-parser==1.6.0",
"posthog==7.44.0",
"psycopg[binary]==3.3.4",
+14
View File
@@ -1327,6 +1327,7 @@ dependencies = [
{ name = "jsonschema" },
{ name = "livekit-api" },
{ name = "markdown" },
{ name = "menshen-client" },
{ name = "mozilla-django-oidc" },
{ name = "nested-multipart-parser" },
{ name = "phonenumbers" },
@@ -1392,6 +1393,7 @@ requires-dist = [
{ name = "jsonschema", specifier = "==4.26.0" },
{ name = "livekit-api", specifier = "==1.2.0" },
{ name = "markdown", specifier = "==3.10.3" },
{ name = "menshen-client", specifier = "==0.1.0" },
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "phonenumbers", specifier = "==9.0.37" },
@@ -1428,6 +1430,18 @@ dev = [
{ name = "types-requests", specifier = "==2.33.0.20260712" },
]
[[package]]
name = "menshen-client"
version = "0.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/d5/c2be400c190efe26b70e5c60dd9d9ab279ece3b51de457abc184a238c727/menshen_client-0.1.0.tar.gz", hash = "sha256:21fe48788e860c7f2e103787ce0827981e9059146cf7bdb9ac1a8778acb9ff77", size = 6219, upload-time = "2026-09-21T21:04:01.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/bf/11d15da4852b8344eb9f30861bd15a8f1d931ff4232d9b7de18fce09fd3c/menshen_client-0.1.0-py3-none-any.whl", hash = "sha256:d0dfb351812bb93620a4796e0f0ce1a40e8ec859dc8de2a1f50ae7f306d4d29a", size = 7040, upload-time = "2026-09-21T21:04:00.433Z" },
]
[[package]]
name = "mozilla-django-oidc"
version = "5.0.2"
+33 -32
View File
@@ -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"
@@ -883,9 +883,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 +901,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"
@@ -1819,9 +1819,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"
@@ -9384,19 +9384,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 +9405,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 +9470,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"
},
+3 -3
View File
@@ -38,11 +38,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"
+2
View File
@@ -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 />
+23 -16
View File
@@ -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."
+5
View File
@@ -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;
}
+1
View File
@@ -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 }}
+29
View File
@@ -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: {}