mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-06 05:22:10 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b86ac35dc6 | |||
| f182474ed9 | |||
| c075db25a4 | |||
| c0dfd88776 | |||
| 94d2b80c17 | |||
| bd2dfcae80 | |||
| 0b4a83c92e | |||
| 9187173cae | |||
| 364bbf4f0b | |||
| a6a12ef586 | |||
| 2622d89f63 | |||
| f2d50770cf | |||
| 11e8470aa5 | |||
| 3bf78f0f5b |
+13
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+14
@@ -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"
|
||||
|
||||
Generated
+33
-32
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user