Compare commits

..

25 Commits

Author SHA1 Message Date
lebaudantoine bb64532cff 🔖(minor) bump release to 1.5.0 2026-01-28 21:28:55 +01:00
Cyril 692c55ed1b Merge branch 'refactor/issue-921-generic-sr-announcer' 2026-01-28 17:07:43 +01:00
lebaudantoine df616ae711 🩹(doc) fix github rendering of docker compose doc
The docker compose rendering was broken because of a recent merge.
Fix it. I've also fixed other minor issues.
2026-01-28 16:17:53 +01:00
Cyril 021d7a7e06 ️(frontend) centralize aria-live announcements in store
avoid per-feature live regions and reduce a11y duplication.
2026-01-28 14:01:35 +01:00
Andrew Hunter f2a3e7c8de 📝(doc) Fix typo 2026-01-28 12:13:19 +01:00
Andrew Hunter cf07ceb67e 🔧(docker) Fix incorrect env variable
Incorrect capitalization prevents correct MEET_HOST variable
subsitution.
2026-01-28 12:13:19 +01:00
Andrew Hunter ea7fb5fc27 📝(doc) Use an empty directory for postgres
Use an empty directory for postgres data, otherwise it will complain the
directory is not empty and fail to start.
2026-01-28 12:13:19 +01:00
Andrew Hunter 6e8a6ce82a 📝(doc) Add -p swich to mkdir
Add the -p switch to create the parent directory before we try to cd
into it.
2026-01-28 12:13:19 +01:00
Andrew Hunter ce960ae330 📝 (doc) Add key gen example
Add a API key generation example using OpenSSL.
2026-01-28 12:13:19 +01:00
Cyril f9dd2e1909 ️(frontend) add global screen reader announcer
centralize live region rendering with a shared announce hook.
2026-01-28 11:44:39 +01:00
Cyril 9023e54352 ️(frontend) add screen reader announcer store
create shared state for screen reader announcements.
2026-01-28 11:40:54 +01:00
Cyril 8295574616 (frontend) sr pin/unpin announcements with dedicated messages
improves accessibility by announcing pin/unpin on state change
2026-01-28 11:13:09 +01:00
Cyril db15c8b6cc ️(frontend) adjust visual-only tooltip a11y labels
Ensure tooltips stay visual while exposing correct aria-labels.
2026-01-28 10:08:01 +01:00
Cyril e1aeec6053 ️(frontend) adjust sr announcements for idle disconnect timer
reduces screen reader noise while keeping key countdown cues
2026-01-27 22:12:55 +01:00
lebaudantoine c5aa762e11 📝(doc) update mosacloud link in the list of saas instances
Link has changed. Update it.
2026-01-27 18:38:34 +01:00
lebaudantoine 8f710a4626 🔒️(frontend) fix an XSS vulnerability on the recording page
An XSS vulnerability was identified by an open-source contributor. While the
impact was limited, only a room owner could inject the content and then view the
recording page, it is important to address, especially before introducing
multi-owner support.
2026-01-27 14:12:45 +01:00
virgile-deville 60d1338eff 📝(readme) mention french state wide deployment
To indicate product maturity to reusers

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2026-01-26 12:04:16 +01:00
lebaudantoine f8436d9ae2 🔖(minor) bump release to 1.4.0 2026-01-25 20:02:37 +01:00
lebaudantoine 39fb273201 💩(ci) disable temporarily Trivy scan step for backend image
A new vulnerability (CVE-2026-0994) was reported and is not yet fixed.
It affects protobuf libraries used by the livekit-api Python package.

A fix is in progress upstream, but the related PR has not yet been merged or
released. Since a release is required tonight, the Trivy scan step is
temporarily disabled to allow the build to proceed. This should be re-enabled
once a patched version is available.

https://github.com/protocolbuffers/protobuf/pull/25239
2026-01-25 18:01:13 +01:00
lebaudantoine d101459115 (frontend) add configurable external redirect for unauthenticated users
Offer a way to redirect unauthenticated users to an external home page when they
visit the app, allowing a more marketing-focused entry point with a clearer
value proposition.

In many self-hosted deployments, the default unauthenticated home page is not
accessible or already redirects elsewhere. To ensure resilience, the client
briefly checks that the target page is reachable and falls back to the default
page if not.
2026-01-25 16:49:56 +01:00
aleb_the_flash 88696a23fd 🩹(doc) update link to the environment variables
Link was invalid. Update it to point to the chart's README file.
Please note this file might be removed.
2026-01-25 00:17:50 +01:00
Cyril 13d26a76b3 (frontend) scope scrollbar gutter override to video rooms
limit scrollbar gutter override to video conference context
2026-01-25 00:07:51 +01:00
lebaudantoine b675517a60 🚧(frontend) debug transcript segment organization
for the big monday demo, push a draft commit.
2026-01-23 19:43:29 +01:00
lebaudantoine a5254ffd59 🔊(frontend) log participant and segments
Log transcription segments to troubleshoot duplication issue.
2026-01-23 18:53:10 +01:00
lebaudantoine ff82bca9ec 🐛(frontend) ensure transcript segments are sorted by their timestamp
Switching from Deepgram to our custom Kyutai implementation introduced changes
in how segment data is returned by the LiveKit agent, so the segment start time
is now treated as optional.
2026-01-23 18:22:40 +01:00
65 changed files with 638 additions and 1042 deletions
+6 -6
View File
@@ -37,12 +37,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
+21
View File
@@ -8,6 +8,25 @@ and this project adheres to
## [Unreleased]
## [1.5.0] - 2026-01-28
### Changed
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
- ♿️(frontend) sr pin/unpin announcements with dedicated messages #898
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
- ♿️(frontend) add global screen reader announcer#922
### Fixed
- 🔒️(frontend) fix an XSS vulnerability on the recording page #911
## [1.4.0] - 2026-01-25
### Added
- ✨(frontend) add configurable redirect for unauthenticated users #904
### Changed
- ♿️(frontend) add accessible back button in side panel #881
@@ -23,6 +42,8 @@ and this project adheres to
- ♿️(frontend) announce copy state in invite dialog #877
- 📝(frontend) align close dialog label in rooms locale #878
- 🩹(backend) use case-insensitive email matching in the external api #887
- 🐛(frontend) ensure transcript segments are sorted by their timestamp #899
- 🐛(frontend) scope scrollbar gutter override to video rooms #882
## [1.3.0] - 2026-01-13
+4 -1
View File
@@ -50,6 +50,9 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
Were continuously adding new features to enhance your experience, with the latest updates coming soon!
### 🚀 Major roll out to all French public servants
On the 25th of January 2026, David Amiel, Frances Minister for Civil Service and State Reform, announced the full deployment of Visio—the French governments dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
## Table of Contents
@@ -86,7 +89,7 @@ We hope to see many more, here is an incomplete list of public La Suite Meet ins
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [meet.demo.mosacloud.eu](https://meet.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing
+1 -1
View File
@@ -10,7 +10,7 @@ services:
- env.d/postgresql
- env.d/common
volumes:
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
- ./data/databases/backend:/var/lib/postgresql/data
redis:
image: redis:5
+2 -2
View File
@@ -8,7 +8,7 @@
### Step 1: Prepare your working environment:
```bash
mkdir keycloak/env.d && cd keycloak
mkdir -p keycloak/env.d && cd keycloak
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
@@ -89,4 +89,4 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
#### Step 3: Get Client Credentials
1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret.
2. Copy the client ID (`meet` in this example) and the client secret.
+12 -3
View File
@@ -47,7 +47,7 @@ curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/m
## Step 2: Configuration
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../../src/helm/meet/README.md).
In this example, we assume the following services:
@@ -129,7 +129,7 @@ The following ports will need to be opened:
- 7881/tcp - WebRTC ICE over TCP
- 7882/udp - for WebRTC multiplexing over UDP
If you are using ufw, enter the follwoing:
If you are using ufw, enter the following:
```
ufw allow 80/tcp
ufw allow 443/tcp
@@ -177,6 +177,15 @@ You will need to uncomment the environment and network sections in compose file
# external: true
```
#### Caddy Reverse Proxy
Expose the Frontend port to the host
```yaml
frontend:
ports:
- "8086:8086"
```
## Step 5: Start Meet
You are ready to start your Meet application !
@@ -198,7 +207,7 @@ Replace `<admin email>` with the email of your admin user and generate a secure
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
THe admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
The admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
## How to upgrade your Meet application
+1 -1
View File
@@ -20,7 +20,7 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
DJANGO_EMAIL_LOGO_IMG="https://${MEET_HOST}/assets/logo-suite-numerique.png"
# Backend url
MEET_BASE_URL="https://${MEET_HOST}"
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.3.0"
version = "1.5.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
-1
View File
@@ -1 +0,0 @@
"""Meet core add-ons module."""
-126
View File
@@ -1,126 +0,0 @@
"""Authentication session management for add-ons using temporary cache-based sessions."""
import secrets
from datetime import datetime, timedelta, timezone
from enum import Enum
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import SuspiciousOperation
from core.models import User
from core.services.jwt_token_service import TokenService
logger = getLogger(__name__)
class SessionState(str, Enum):
"""Add-on authentication session states."""
PENDING = "pending"
AUTHENTICATED = "authenticated"
class TokenExchangeService:
"""Manage temporary authentication sessions for add-on JWT token exchange."""
def __init__(self):
"""Initialize the service with the configured token service."""
self._token_service = TokenService(
secret_key=settings.ADDONS_JWT_SECRET_KEY,
algorithm=settings.ADDONS_JWT_ALG,
issuer=settings.ADDONS_JWT_ISSUER,
audience=settings.ADDONS_JWT_AUDIENCE,
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
)
def _get_cache_key(self, session_id: str) -> str:
"""Generate cache key for a session ID."""
return f"{settings.ADDONS_SESSION_KEY_PREFIX}_{session_id}"
def init_session(self) -> str:
"""Create a new pending authentication session and return its ID."""
session_id = secrets.token_urlsafe(settings.ADDONS_SESSION_ID_LENGTH)
expires_at = datetime.now(timezone.utc) + timedelta(
seconds=settings.ADDONS_SESSION_TIMEOUT
)
session_data = {
"state": SessionState.PENDING,
"expires_at": expires_at.isoformat(),
}
cache_key = self._get_cache_key(session_id)
cache.set(
cache_key,
session_data,
timeout=settings.ADDONS_SESSION_TIMEOUT,
)
return session_id
def get_session(self, session_id: str) -> dict:
"""Retrieve session data and clear it if authenticated."""
cache_key = self._get_cache_key(session_id)
data = cache.get(cache_key)
if not data:
return {}
if data.get("state") == SessionState.AUTHENTICATED:
self.clear_session(session_id)
# Return copy without internal fields
internal_fields = {"expires_at"}
return {k: v for k, v in data.items() if k not in internal_fields}
def clear_session(self, session_id: str) -> None:
"""Remove session data from cache."""
cache_key = self._get_cache_key(session_id)
cache.delete(cache_key)
def set_access_token(self, user: User, session_id: str):
"""Generate and store access token for an authenticated user session."""
cache_key = self._get_cache_key(session_id)
existing_data = cache.get(cache_key)
if not existing_data:
raise SuspiciousOperation("Session not found.")
expires_at = existing_data.get("expires_at", None)
if not expires_at:
self.clear_session(session_id)
raise SuspiciousOperation("Invalid session data.")
remaining_seconds = int(
(
datetime.fromisoformat(expires_at) - datetime.now(timezone.utc)
).total_seconds()
)
if remaining_seconds <= 0:
self.clear_session(session_id)
raise SuspiciousOperation("Session expired.")
if existing_data.get("state") != SessionState.PENDING:
self.clear_session(session_id)
raise SuspiciousOperation("Access token already set.")
response = self._token_service.generate_access_token(
user, settings.ADDONS_SCOPES
)
new_data = {
**existing_data,
**response,
"state": SessionState.AUTHENTICATED,
}
cache.set(cache_key, new_data, timeout=remaining_seconds)
-57
View File
@@ -1,57 +0,0 @@
"""Add-ons views."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from core.addons.service import SessionState, TokenExchangeService
def render_error(request, message, status=400):
"""Render simple error page."""
return render(request, "addons/error.html", {"message": message}, status=status)
@require_http_methods(["GET"])
def transit_page(request):
"""Initialize authentication flow for add-on session."""
session_id = request.GET.get("session_id")
if not session_id:
return render_error(request, _("Session ID is required."), status=400)
data = TokenExchangeService().get_session(session_id)
if not data:
return render_error(request, _("Session not found or expired."), status=404)
if data.get("state") != SessionState.PENDING:
return render_error(request, _("Invalid session state."), status=400)
request.session[settings.ADDONS_SESSION_KEY_AUTH] = session_id
return_to = request.build_absolute_uri("/addons/redirect")
return redirect(f"/api/{settings.API_VERSION}/authenticate/?returnTo={return_to}")
@require_http_methods(["GET"])
def redirect_page(request):
"""Complete authentication and close the popup window."""
if not request.user.is_authenticated:
return render_error(request, _("Authentication required."), status=401)
session_id = request.session.pop(settings.ADDONS_SESSION_KEY_AUTH, None)
if not session_id:
return render_error(request, _("No active session found."), status=404)
try:
TokenExchangeService().set_access_token(request.user, session_id)
except SuspiciousOperation:
return render_error(request, _("Invalid or expired session."), status=400)
return render(request, "addons/redirect_success.html")
-53
View File
@@ -1,53 +0,0 @@
"""Add-ons API endpoints"""
from logging import getLogger
from rest_framework import (
response as drf_response,
)
from rest_framework import status as drf_status
from rest_framework import throttling, viewsets
from core.addons.service import TokenExchangeService
logger = getLogger(__name__)
class AuthSessionThrottle(throttling.AnonRateThrottle):
"""Throttle request to the addons auth session endpoints."""
scope = "addons_auth_sessions"
class AuthSessionViewSet(viewsets.ViewSet):
"""ViewSet for managing add-on authentication sessions via token exchange."""
authentication_classes = []
permission_classes = []
throttle_classes = [AuthSessionThrottle]
def create(self, request):
"""Create a new pending authentication session."""
session_id = TokenExchangeService().init_session()
return drf_response.Response(
{"session_id": session_id}, status=drf_status.HTTP_201_CREATED
)
def retrieve(self, request, pk=None):
"""Retrieve authentication session data by session ID."""
data = TokenExchangeService().get_session(pk)
if not data:
return drf_response.Response(
{"detail": "Session not found or expired."},
status=drf_status.HTTP_404_NOT_FOUND,
)
return drf_response.Response(data, status=drf_status.HTTP_200_OK)
def destroy(self, request, pk=None):
"""Delete an authentication session by session ID."""
TokenExchangeService().clear_session(pk)
return drf_response.Response(
{"status": "ok"}, status=drf_status.HTTP_204_NO_CONTENT
)
+42 -120
View File
@@ -14,13 +14,12 @@ User = get_user_model()
logger = logging.getLogger(__name__)
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
"""JWT authentication for application-delegated API access.
secret_key = None
algorithm = None
issuer = None
audience = None
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
def authenticate(self, request):
"""Extract and validate JWT from Authorization header.
@@ -47,87 +46,6 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
return self.authenticate_credentials(token)
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict, or None if token is invalid
Raises:
AuthenticationFailed: If token is expired or has invalid issuer/audience
"""
try:
payload = pyJwt.decode(
token,
self.secret_key,
algorithms=[self.algorithm],
issuer=self.issuer,
audience=self.audience,
)
return payload
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
return None
def validate_payload(self, payload):
"""Validate JWT payload claims.
Override in subclasses to add custom validation.
Args:
payload: Decoded JWT payload
Raises:
AuthenticationFailed: If required claims are missing or invalid
"""
def get_user(self, payload):
"""Retrieve and validate user from payload.
Args:
payload: Decoded JWT payload
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
user_id = payload.get("user_id")
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.
@@ -142,35 +60,36 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
Raises:
AuthenticationFailed: If token is expired, or user not found
"""
payload = self.decode_jwt(token)
if payload is None:
# Decode and validate JWT
try:
payload = pyJwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
return None
self.validate_payload(payload)
user = self.get_user(payload)
return (user, payload)
class ApplicationJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for application-delegated API access.
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
secret_key = settings.APPLICATION_JWT_SECRET_KEY
algorithm = settings.APPLICATION_JWT_ALG
issuer = settings.APPLICATION_JWT_ISSUER
audience = settings.APPLICATION_JWT_AUDIENCE
def validate_payload(self, payload):
"""Validate application-specific claims."""
user_id = payload.get("user_id")
client_id = payload.get("client_id")
is_delegated = payload.get("delegated", False)
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not client_id:
logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
@@ -179,18 +98,21 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
class AddonsJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for addons API access.
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
Validates JWT tokens issued by addons for authenticating users.
Tokens must include user_id to identify the authenticated user.
"""
return (user, payload)
secret_key = settings.ADDONS_JWT_SECRET_KEY
algorithm = settings.ADDONS_JWT_ALG
issuer = settings.ADDONS_JWT_ISSUER
audience = settings.ADDONS_JWT_AUDIENCE
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
class ResourceServerBackend(LaSuiteBackend):
+23 -18
View File
@@ -1,5 +1,6 @@
"""External API endpoints"""
from datetime import datetime, timedelta, timezone
from logging import getLogger
from django.conf import settings
@@ -7,6 +8,7 @@ from django.contrib.auth.hashers import check_password
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.validators import validate_email
import jwt
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import decorators, mixins, viewsets
from rest_framework import (
@@ -20,7 +22,6 @@ from rest_framework import (
)
from core import api, models
from core.services.jwt_token_service import TokenService
from . import authentication, permissions, serializers
@@ -127,28 +128,33 @@ class ApplicationViewSet(viewsets.GenericViewSet):
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
token_service = TokenService(
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": client_id,
"scope": scope,
"user_id": str(user.id),
"delegated": True,
}
data = token_service.generate_access_token(
user,
scope,
{
"client_id": client_id,
"delegated": True,
},
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
return drf_response.Response(
data,
{
"access_token": token,
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": scope,
},
status=drf_status.HTTP_200_OK,
)
@@ -173,7 +179,6 @@ class RoomViewSet(
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
@@ -1,97 +0,0 @@
"""JWT token service."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
from datetime import datetime, timedelta, timezone
from typing import Optional
from django.core.exceptions import ImproperlyConfigured
import jwt
class TokenService:
"""Generic JWT token service with configurable settings."""
def __init__(
self,
secret_key: str,
algorithm: str,
issuer: str,
audience: str,
expiration_seconds: int,
token_type: str,
):
"""
Initialize the token service with custom settings.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm (default: HS256)
issuer: Token issuer identifier
audience: Token audience identifier
expiration_seconds: Token expiration time in seconds (default: 3600)
token_type: Token type (default: Bearer)
Raises:
ImproperlyConfigured: If secret_key is None or empty
"""
if not secret_key:
raise ImproperlyConfigured("Secret key is required.")
self._key = secret_key
self._alg = algorithm
self._issuer = issuer
self._audience = audience
self._expiration_seconds = expiration_seconds
self._token_type = token_type
def generate_access_token(
self, user, scope: str, extra_payload: Optional[dict] = None
) -> dict:
"""
Generate an access token for the given user.
Args:
user: User instance for whom to generate the token
scope: Space-separated scope string
Returns:
Dictionary containing access_token, token_type, expires_in, and scope
"""
now = datetime.now(timezone.utc)
payload = extra_payload.copy() if extra_payload else {}
payload.update(
{
"iat": now,
"exp": now + timedelta(seconds=self._expiration_seconds),
"user_id": str(user.id),
}
)
if self._issuer:
payload["iss"] = self._issuer
if self._audience:
payload["aud"] = self._audience
if scope:
payload["scope"] = scope
token = jwt.encode(
payload,
self._key,
algorithm=self._alg,
)
response = {
"access_token": token,
"token_type": self._token_type,
"expires_in": self._expiration_seconds,
}
if scope:
response["scope"] = scope
return response
@@ -1,17 +0,0 @@
{% load i18n %}
{% get_current_language as LANGUAGE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="UTF-8">
<title>{% trans "Error" %}</title>
</head>
<body>
<div class="container">
<h1>{{ title|default:_("Error") }}</h1>
<p>{{ message|default:_("Something went wrong.") }}</p>
<button onclick="window.close()">{% trans "Close" %}</button>
</div>
</body>
</html>
@@ -1,17 +0,0 @@
{% load i18n %}
{% get_current_language as LANGUAGE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="UTF-8">
<title>{% trans "Authentication Success" %}</title>
</head>
<body>
<script>
window.close();
</script>
<p>{% trans "Session stored successfully. This window will close automatically." %}</p>
<p>{% trans "If it doesn't close" %}, <a href="javascript:window.close()">{% trans "click here" %}</a>.</p>
</body>
</html>
-28
View File
@@ -6,8 +6,6 @@ from django.urls import include, path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.addons import views as addons_views
from core.addons import viewsets as addons_viewsets
from core.api import get_frontend_configuration, viewsets
from core.external_api import viewsets as external_viewsets
@@ -28,24 +26,12 @@ external_router.register(
basename="external_application",
)
# - Addons API
addons_router = DefaultRouter()
addons_router.register(
"addons/sessions",
addons_viewsets.AuthSessionViewSet,
basename="addons_auth_sessions",
)
external_router.register(
"rooms",
external_viewsets.RoomViewSet,
basename="external_room",
)
addons_urls = addons_router.urls if settings.ADDONS_ENABLED else []
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
@@ -53,26 +39,12 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*addons_urls,
path("config/", get_frontend_configuration, name="config"),
]
),
),
]
if settings.ADDONS_ENABLED:
urlpatterns.append(
path(
"addons/",
include(
[
path("transit/", addons_views.transit_page, name="transit_page"),
path("redirect/", addons_views.redirect_page, name="redirect_page"),
]
),
),
)
if settings.EXTERNAL_API_ENABLED:
urlpatterns.append(
path(
Binary file not shown.
+2 -50
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Sitzungs-ID ist erforderlich."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Sitzung nicht gefunden oder abgelaufen."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Ungültiger Sitzungsstatus."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentifizierung erforderlich."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Keine aktive Sitzung gefunden."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Ungültige oder abgelaufene Sitzung."
#: core/admin.py:29
msgid "Personal info"
msgstr "Persönliche Informationen"
@@ -432,7 +408,7 @@ msgstr "Anwendungsdomain"
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -441,30 +417,6 @@ msgstr "Ihre Aufzeichnung ist bereit"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Fehler"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Etwas ist schiefgelaufen."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Schließen"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentifizierung erfolgreich"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Sitzung erfolgreich gespeichert. Dieses Fenster wird automatisch geschlossen."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Falls es sich nicht schließt"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
Binary file not shown.
+2 -50
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Session ID is required."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Session not found or expired."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Invalid session state."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentication required."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "No active session found."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Invalid or expired session."
#: core/admin.py:29
msgid "Personal info"
msgstr "Personal info"
@@ -429,7 +405,7 @@ msgstr "Application domain"
msgid "Application domains"
msgstr "Application domains"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -438,30 +414,6 @@ msgstr "Your recording is ready"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Error"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Something went wrong."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Close"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentication Success"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Session stored successfully. This window will close automatically."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "If it doesn't close"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
Binary file not shown.
+2 -50
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "L'identifiant de session est requis."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Session introuvable ou expirée."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "État de session invalide."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentification requise."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Aucune session active trouvée."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Session invalide ou expirée."
#: core/admin.py:29
msgid "Personal info"
msgstr "Informations personnelles"
@@ -433,7 +409,7 @@ msgstr "Domaine dapplication"
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -442,30 +418,6 @@ msgstr "Votre enregistrement est prêt"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Erreur"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Une erreur s'est produite."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Fermer"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentification réussie"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Session enregistrée avec succès. Cette fenêtre se fermera automatiquement."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Si elle ne se ferme pas"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
Binary file not shown.
+2 -50
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Sessie-ID is vereist."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Sessie niet gevonden of verlopen."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Ongeldige sessiestatus."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authenticatie vereist."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Geen actieve sessie gevonden."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Ongeldige of verlopen sessie."
#: core/admin.py:29
msgid "Personal info"
msgstr "Persoonlijke informatie"
@@ -428,7 +404,7 @@ msgstr "Applicatiedomein"
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -437,30 +413,6 @@ msgstr "Je opname is klaar"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Fout"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Er is iets misgegaan."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Sluiten"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authenticatie geslaagd"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Sessie succesvol opgeslagen. Dit venster wordt automatisch gesloten."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Als het niet sluit"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
+3 -65
View File
@@ -290,11 +290,6 @@ class Base(Configuration):
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
"addons_auth_sessions": values.Value(
default="150/minute",
environ_name="ADDONS_AUTH_SESSION_THROTTLE_RATES",
environ_prefix=None,
),
},
}
@@ -341,6 +336,9 @@ class Base(Configuration):
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
"external_home_url": values.Value(
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
),
"use_french_gov_footer": values.BooleanValue(
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
),
@@ -791,66 +789,6 @@ class Base(Configuration):
environ_prefix=None,
)
# Addons
ADDONS_ENABLED = values.BooleanValue(
False,
environ_name="ADDONS_ENABLED",
environ_prefix=None,
)
ADDONS_SESSION_ID_LENGTH = values.PositiveIntegerValue(
32,
environ_name="ADDONS_SESSION_ID_LENGTH",
environ_prefix=None,
)
# Used in cache key generation
ADDONS_SESSION_KEY_PREFIX = values.Value(
"addons_session_id",
environ_name="ADDONS_SESSION_KEY_PREFIX",
environ_prefix=None,
)
# Used as the Django session key in transit page
ADDONS_SESSION_KEY_AUTH = values.Value(
"addons_session_id",
environ_name="ADDONS_SESSION_KEY_AUTH",
environ_prefix=None,
)
ADDONS_SESSION_TIMEOUT = values.PositiveIntegerValue(
600, environ_name="ADDONS_SESSION_TIMEOUT", environ_prefix=None
)
ADDONS_JWT_SECRET_KEY = SecretFileValue(
None, environ_name="ADDONS_JWT_SECRET_KEY", environ_prefix=None
)
ADDONS_JWT_ALG = values.Value(
"HS256",
environ_name="ADDONS_JWT_ALG",
environ_prefix=None,
)
ADDONS_SCOPES = values.Value(
"rooms:create rooms:list",
environ_name="ADDONS_SCOPES",
environ_prefix=None,
)
ADDONS_JWT_ISSUER = values.Value(
"lasuite-meet",
environ_name="ADDONS_JWT_ISSUER",
environ_prefix=None,
)
ADDONS_JWT_AUDIENCE = values.Value(
None,
environ_name="ADDONS_JWT_AUDIENCE",
environ_prefix=None,
)
ADDONS_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue(
3600,
environ_name="ADDONS_JWT_EXPIRATION_SECONDS",
environ_prefix=None,
)
ADDONS_JWT_TOKEN_TYPE = values.Value(
"Bearer",
environ_name="ADDONS_JWT_TOKEN_TYPE",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.3.0"
version = "1.5.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.3.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.3.0",
"version": "1.5.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.3.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+1
View File
@@ -17,6 +17,7 @@ export interface ApiConfig {
feedback: {
url: string
}
external_home_url?: string
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
custom_css_url?: string
+27 -1
View File
@@ -11,7 +11,7 @@ import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useState } from 'react'
import { ReactNode, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
@@ -19,6 +19,7 @@ import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePers
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { LoadingScreen } from '@/components/LoadingScreen'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -155,9 +156,34 @@ export const Home = () => {
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
const [redirectFailed, setRedirectFailed] = useState(false)
const { data } = useConfig()
useEffect(() => {
const checkSiteAndRedirect = async () => {
if (!data?.external_home_url) return
if (isLoggedIn === false) {
try {
await fetch(data.external_home_url, {
method: 'HEAD', // Use HEAD to avoid downloading the full page
mode: 'no-cors', // Needed for cross-origin requests
})
window.location.replace(data.external_home_url)
} catch (error) {
setRedirectFailed(true)
console.error('Site is not reachable:', error)
}
}
}
checkSiteAndRedirect()
}, [isLoggedIn, data])
if (data?.external_home_url && isLoggedIn == false && !redirectFailed) {
return <LoadingScreen header={false} footer={false} delay={0} />
}
return (
<UserAware>
<Screen>
@@ -1,6 +1,6 @@
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useMemo, useRef, useState, useEffect } from 'react'
import { useMemo, useRef, useEffect } from 'react'
import { Text } from '@/primitives'
import {
RecordingMode,
@@ -13,6 +13,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useRoomMetadata } from '../hooks/useRoomMetadata'
import { RecordingStatusIcon } from './RecordingStatusIcon'
import { useIsRecording } from '@livekit/components-react'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', {
@@ -21,8 +22,8 @@ export const RecordingStateToast = () => {
const { openTranscript, openScreenRecording } = useSidePanel()
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const announce = useScreenReaderAnnounce()
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
@@ -76,16 +77,9 @@ export const RecordingStateToast = () => {
if (key && key !== lastKeyRef.current) {
lastKeyRef.current = key
const message = t(key)
setSrMessage(message)
// Clear message after 3 seconds to prevent it from being announced again
const timer = setTimeout(() => {
setSrMessage('')
}, 3000)
return () => clearTimeout(timer)
announce(message)
}
}, [key, t])
}, [announce, key, t])
if (!key) return null
@@ -95,15 +89,6 @@ export const RecordingStateToast = () => {
return (
<>
{/* Screen reader only message to announce state changes once */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{srMessage}
</div>
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
<div
className={css({
@@ -104,14 +104,12 @@ export const RecordingDownload = () => {
{t('success.title')}
</H>
<Text centered margin="md" wrap={'balance'}>
<span
dangerouslySetInnerHTML={{
__html: t('success.body', {
room: data.room.name,
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
}),
}}
/>
<span>
{t('success.body', {
room: data.room.name,
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
})}
</span>
<span>
{configData?.recording?.expiration_days && (
<>
@@ -1,21 +1,26 @@
import { Button, Dialog, H, P } from '@/primitives'
import { Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
const FINAL_COUNTDOWN_SECONDS = 10
export const IsIdleDisconnectModal = () => {
const connectionObserverSnap = useSnapshot(connectionObserverStore)
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
const lastAnnouncementRef = useRef<number | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
const announce = useScreenReaderAnnounce()
useEffect(() => {
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
@@ -35,10 +40,42 @@ export const IsIdleDisconnectModal = () => {
}
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
const minutes = Math.floor(timeRemaining / 1000 / 60)
const seconds = (timeRemaining / 1000) % 60
useEffect(() => {
if (!connectionObserverSnap.isIdleDisconnectModalOpen) {
lastAnnouncementRef.current = null
}
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
const remainingSeconds = Math.floor(timeRemaining / 1000)
const minutes = Math.floor(remainingSeconds / 60)
const seconds = remainingSeconds % 60
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
useEffect(() => {
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
const shouldAnnounce =
COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
lastAnnouncementRef.current = remainingSeconds
const message = t('countdownAnnouncement', {
duration: humanizeDuration(remainingSeconds * 1000, {
language: i18n.language,
round: false,
largest: 2,
}),
})
announce(message, 'assertive', 'idle')
}
}, [
announce,
connectionObserverSnap.isIdleDisconnectModalOpen,
remainingSeconds,
t,
])
return (
<Dialog
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
@@ -52,6 +89,7 @@ export const IsIdleDisconnectModal = () => {
{({ close }) => {
return (
<div>
<ScreenReaderAnnouncer channel="idle" />
<div
className={css({
height: '50px',
@@ -65,6 +103,7 @@ export const IsIdleDisconnectModal = () => {
color: 'blue.800',
margin: 'auto',
})}
aria-hidden="true"
>
{formattedTime}
</div>
@@ -8,6 +8,7 @@ import { Reaction } from '@/features/rooms/livekit/components/controls/Reactions
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { accessibilityStore } from '@/stores/accessibility'
import { useSnapshot } from 'valtio'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
@@ -146,15 +147,14 @@ export function ReactionPortal({
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { announceReactions } = useSnapshot(accessibilityStore)
const [announcement, setAnnouncement] = useState<string | null>(null)
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
const announce = useScreenReaderAnnounce()
const latestReaction =
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
useEffect(() => {
if (!announceReactions) {
setAnnouncement(null)
return
}
if (!latestReaction) return
@@ -166,12 +166,9 @@ export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel }))
announce(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
const timer = setTimeout(() => setAnnouncement(null), 1200)
return () => clearTimeout(timer)
}, [latestReaction, lastAnnouncedId, announceReactions, t])
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
return (
<>
@@ -182,14 +179,6 @@ export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
participant={instance.participant}
/>
))}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement ?? ''}
</div>
</>
)
}
@@ -18,6 +18,7 @@ import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
enum BlurRadius {
NONE = 0,
@@ -56,7 +57,7 @@ export const EffectsConfiguration = ({
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const [effectStatusMessage, setEffectStatusMessage] = useState('')
const announce = useScreenReaderAnnounce()
const effectAnnouncementTimeout = useRef<ReturnType<
typeof setTimeout
> | null>(null)
@@ -104,12 +105,9 @@ export const EffectsConfiguration = ({
clearTimeout(effectAnnouncementTimeout.current)
}
// Clear the region first so screen readers drop queued announcements.
setEffectStatusMessage('')
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
setEffectStatusMessage(message)
announce(message)
}, 80)
}
@@ -423,9 +421,6 @@ export const EffectsConfiguration = ({
<BlurOnStrong />
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{effectStatusMessage}
</div>
</div>
<div
className={css({
@@ -5,9 +5,8 @@ import {
isWeb,
log,
} from '@livekit/components-core'
import { RoomEvent, Track } from 'livekit-client'
import * as React from 'react'
import { useState } from 'react'
import { Participant, RoomEvent, Track } from 'livekit-client'
import React, { useCallback, useRef, useState, useEffect } from 'react'
import {
ConnectionStateToast,
FocusLayoutContainer,
@@ -16,7 +15,9 @@ import {
usePinnedTracks,
useTracks,
useCreateLayoutContext,
useRoomContext,
} from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { ControlBar } from './ControlBar/ControlBar'
import { styled } from '@/styled-system/jsx'
@@ -37,6 +38,8 @@ import { Subtitles } from '@/features/subtitle/component/Subtitles'
import { CarouselLayout } from '../components/layout/CarouselLayout'
import { GridLayout } from '../components/layout/GridLayout'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const LayoutWrapper = styled(
'div',
@@ -89,7 +92,22 @@ export interface VideoConferenceProps
*/
export function VideoConference({ ...props }: VideoConferenceProps) {
const lastAutoFocusedScreenShareTrack =
React.useRef<TrackReferenceOrPlaceholder | null>(null)
useRef<TrackReferenceOrPlaceholder | null>(null)
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
const { t: tRooms } = useTranslation('rooms')
const room = useRoomContext()
const announce = useScreenReaderAnnounce()
const getAnnouncementName = useCallback(
(participant?: Participant | null) => {
if (!participant) return tRooms('participants.unknown')
return participant.isLocal
? tRooms('participants.you')
: getParticipantName(participant)
},
[tRooms]
)
useConnectionObserver()
useVideoResolutionSubscription()
@@ -115,9 +133,62 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
(track) => !isEqualTrackRef(track, focusTrack)
)
// handle pin announcements
useEffect(() => {
const participant = focusTrack?.participant
// 1. unpin
if (!participant) {
if (!lastPinnedParticipantIdentityRef.current) return
const lastIdentity = lastPinnedParticipantIdentityRef.current
const lastParticipant =
room.localParticipant.identity === lastIdentity
? room.localParticipant
: room.remoteParticipants.get(lastIdentity)
const announcementName = getAnnouncementName(lastParticipant)
announce(
lastParticipant?.isLocal
? t('self.unpin')
: t('unpin', {
name: announcementName,
})
)
lastPinnedParticipantIdentityRef.current = null
return
}
// 2. same pin → do nothing
if (lastPinnedParticipantIdentityRef.current === participant.identity) {
return
}
// 3. new pin
const participantName = participant.isLocal
? tRooms('participants.you')
: getParticipantName(participant)
lastPinnedParticipantIdentityRef.current = participant.identity
announce(
participant.isLocal ? t('self.pin') : t('pin', { name: participantName })
)
}, [
announce,
focusTrack,
getAnnouncementName,
room.localParticipant,
room.remoteParticipants,
t,
tRooms,
])
/* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
React.useEffect(() => {
useEffect(() => {
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
if (
screenShareTracks.some((track) => track.publication.isSubscribed) &&
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useSubtitles } from '../hooks/useSubtitles'
import { css, cva } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx'
@@ -13,101 +13,63 @@ export interface TranscriptionSegment {
id: string
text: string
language: string
startTime: number
startTime?: number
endTime: number
final: boolean
firstReceivedTime: number
lastReceivedTime: number
}
export interface TranscriptionSegmentWithParticipant
extends TranscriptionSegment {
participant: Participant
}
export interface TranscriptionRow {
id: string
participant: Participant
segments: TranscriptionSegment[]
startTime: number
startTime?: number
lastUpdateTime: number
lastReceivedTime: number
}
const useTranscriptionState = () => {
const [transcriptionRows, setTranscriptionRows] = useState<
TranscriptionRow[]
const [transcriptionSegments, setTranscriptionSegments] = useState<
TranscriptionSegmentWithParticipant[]
>([])
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
useState<string | null>(null)
const updateTranscriptions = (
const updateTranscriptionSegments = (
segments: TranscriptionSegment[],
participant?: Participant
) => {
console.log(participant, segments)
if (!participant || segments.length === 0) return
setTranscriptionRows((prevRows) => {
const updatedRows = [...prevRows]
const now = Date.now()
if (segments.length > 1) {
console.warn('Unexpected error more segments')
return
}
const shouldAppendToLastRow =
lastActiveParticipantIdentity === participant.identity &&
updatedRows.length > 0
const segment = segments[0]
if (shouldAppendToLastRow) {
const lastRowIndex = updatedRows.length - 1
const lastRow = updatedRows[lastRowIndex]
const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id))
const newSegments = segments.filter(
(segment) => !existingSegmentIds.has(segment.id)
)
const updatedSegments = lastRow.segments.map((existing) => {
const update = segments.find((s) => s.id === existing.id)
return update && update.final ? update : existing
})
updatedRows[lastRowIndex] = {
...lastRow,
segments: [...updatedSegments, ...newSegments],
lastUpdateTime: now,
}
} else {
const newRow: TranscriptionRow = {
id: `${participant.identity}-${now}`,
participant,
segments: [...segments],
startTime: Math.min(...segments.map((s) => s.startTime)),
lastUpdateTime: now,
}
updatedRows.push(newRow)
}
return updatedRows
})
setLastActiveParticipantIdentity(participant.identity)
}
const clearTranscriptions = () => {
setTranscriptionRows([])
setLastActiveParticipantIdentity(null)
}
const updateParticipant = (_name: string, participant: Participant) => {
setTranscriptionRows((prevRows) => {
return prevRows.map((row) => {
if (row.participant.identity === participant.identity) {
return {
...row,
participant,
}
}
return row
})
setTranscriptionSegments((prevSegments) => {
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
if (existingSegmentIds.has(segment.id)) return prevSegments
return [
...prevSegments,
{
participant: participant,
...segment,
},
]
})
}
return {
transcriptionRows,
updateTranscriptions,
clearTranscriptions,
updateParticipant,
updateTranscriptionSegments,
transcriptionSegments,
}
}
@@ -192,24 +154,54 @@ const SubtitlesWrapper = styled(
export const Subtitles = () => {
const { areSubtitlesOpen } = useSubtitles()
const room = useRoomContext()
const { transcriptionRows, updateTranscriptions, updateParticipant } =
const { transcriptionSegments, updateTranscriptionSegments } =
useTranscriptionState()
useEffect(() => {
if (!room) return
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions)
room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
return () => {
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions)
room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
}
}, [room, updateTranscriptions])
}, [room, updateTranscriptionSegments])
useEffect(() => {
if (!room) return
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
return () => {
room.off(RoomEvent.ParticipantNameChanged, updateParticipant)
const transcriptionRows = useMemo(() => {
if (transcriptionSegments.length === 0) return []
const rows: TranscriptionRow[] = []
let currentRow: TranscriptionRow | null = null
for (const segment of transcriptionSegments) {
const shouldStartNewRow =
!currentRow ||
currentRow.participant.identity !== segment.participant.identity
if (shouldStartNewRow) {
currentRow = {
id: `${segment.participant.identity}-${segment.firstReceivedTime}`,
participant: segment.participant,
segments: [segment],
startTime: segment.startTime,
lastUpdateTime: segment.lastReceivedTime,
lastReceivedTime: segment.lastReceivedTime,
}
rows.push(currentRow)
} else if (currentRow) {
currentRow.segments.push(segment)
currentRow.lastUpdateTime = Math.max(
currentRow.lastUpdateTime,
segment.lastReceivedTime
)
currentRow.lastReceivedTime = Math.max(
currentRow.lastReceivedTime,
segment.lastReceivedTime
)
}
}
}, [room, updateParticipant])
return rows
}, [transcriptionSegments])
return (
<SubtitlesWrapper areOpen={areSubtitlesOpen}>
@@ -0,0 +1,19 @@
import { useCallback } from 'react'
import {
announceToScreenReader,
type Politeness,
type ScreenReaderChannel,
} from '@/stores/screenReaderAnnouncer'
export const useScreenReaderAnnounce = () => {
return useCallback(
(
message: string,
politeness: Politeness = 'polite',
channel: ScreenReaderChannel = 'global'
) => {
announceToScreenReader(message, politeness, channel)
},
[]
)
}
+23 -16
View File
@@ -10,6 +10,7 @@ import { FeedbackBanner } from '@/components/FeedbackBanner'
import { Menu } from '@/primitives/Menu'
import { MenuList } from '@/primitives/MenuList'
import { LoginButton } from '@/components/LoginButton'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useLoginHint } from '@/hooks/useLoginHint'
@@ -90,6 +91,11 @@ export const Header = () => {
const isTermsOfService = useMatchesRoute('termsOfService')
const isRoom = useMatchesRoute('room')
const { user, isLoggedIn, logout } = useUser()
const userLabel = user?.full_name || user?.email
const loggedInTooltip = t('loggedInUserTooltip')
const loggedInAriaLabel = userLabel
? `${loggedInTooltip} ${userLabel}`
: loggedInTooltip
return (
<>
@@ -153,23 +159,24 @@ export const Header = () => {
)}
{!!user && (
<Menu>
<Button
size="sm"
variant="secondaryText"
tooltip={t('loggedInUserTooltip')}
tooltipType="delayed"
>
<span
className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '350px',
display: { base: 'none', xsm: 'block' },
})}
<Button size="sm" variant="secondaryText">
<VisualOnlyTooltip
tooltip={loggedInTooltip}
ariaLabel={loggedInAriaLabel}
tooltipPosition="bottom"
>
{user?.full_name || user?.email}
</span>
<span
className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '350px',
display: { base: 'none', xsm: 'block' },
})}
>
{user?.full_name || user?.email}
</span>
</VisualOnlyTooltip>
</Button>
<MenuList
variant={'light'}
+2
View File
@@ -4,6 +4,7 @@ import { Header } from './Header'
import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio'
import { Footer } from '@/layout/Footer'
import { ScreenReaderAnnouncer } from '@/primitives'
export type Layout = 'fullpage' | 'centered'
@@ -41,6 +42,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
flexDirection: 'column',
})}
>
<ScreenReaderAnnouncer />
{children}
</main>
</div>
+1 -1
View File
@@ -17,7 +17,7 @@
},
"success": {
"title": "Ihre Aufzeichnung ist bereit!",
"body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.",
"body": "Aufzeichnung des Treffens {{room}} vom {{created_at}}.",
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
"button": "Herunterladen",
"warning": {
+11 -1
View File
@@ -156,7 +156,8 @@
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
"settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.",
"stayButton": "Gespräch fortsetzen",
"leaveButton": "Jetzt verlassen"
"leaveButton": "Jetzt verlassen",
"countdownAnnouncement": "Das Gespräch endet in {{duration}}."
},
"controls": {
"microphone": "Mikrofon",
@@ -504,6 +505,7 @@
"participants": {
"subheading": "Im Raum",
"you": "Du",
"unknown": "Unbekannter Teilnehmer",
"host": "Host",
"contributors": "Mitwirkende",
"collapsable": {
@@ -551,6 +553,14 @@
"ariaLabel": "Hefte {{name}} an"
}
},
"pinAnnouncements": {
"pin": "Das Video von {{name}} ist angeheftet.",
"unpin": "Das Video von {{name}} ist nicht mehr angeheftet.",
"self": {
"pin": "Dein Video ist angeheftet.",
"unpin": "Dein Video ist nicht mehr angeheftet."
}
},
"recordingStateToast": {
"transcript": {
"started": "Transkription läuft",
+1 -1
View File
@@ -13,7 +13,7 @@
"heading": "You don't have the permission to view this page"
},
"loading": "Loading…",
"loggedInUserTooltip": "Logged in as",
"loggedInUserTooltip": "Logged in as ",
"login": {
"buttonLabel": "Login",
"proconnectButtonLabel": "Login with ProConnect",
+1 -1
View File
@@ -17,7 +17,7 @@
},
"success": {
"title": "Your recording is ready!",
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.",
"body": "Recording of the meeting {{room}} from {{created_at}}.",
"expiration": "Attention, this recording will expire after {{expiration_days}} day(s).",
"button": "Download",
"warning": {
+11 -1
View File
@@ -156,7 +156,8 @@
"body": "You are the only participant. This call will end in {{duration}}. Would you like to continue the call?",
"settings": "To stop seeing this message, go to Settings > General.",
"stayButton": "Continue the call",
"leaveButton": "Leave now"
"leaveButton": "Leave now",
"countdownAnnouncement": "Call ends in {{duration}}."
},
"controls": {
"microphone": "Microphone",
@@ -504,6 +505,7 @@
"participants": {
"subheading": "In room",
"you": "You",
"unknown": "Unknown participant",
"host": "Host",
"contributors": "Contributors",
"collapsable": {
@@ -551,6 +553,14 @@
"ariaLabel": "Pin {{name}}"
}
},
"pinAnnouncements": {
"pin": "The video of {{name}} is pinned.",
"unpin": "The video of {{name}} is no longer pinned.",
"self": {
"pin": "Your video is pinned.",
"unpin": "Your video is no longer pinned."
}
},
"recordingStateToast": {
"transcript": {
"started": "Transcribing",
+1 -1
View File
@@ -13,7 +13,7 @@
"heading": "Accès interdit"
},
"loading": "Chargement…",
"loggedInUserTooltip": "Connecté en tant que",
"loggedInUserTooltip": "Connecté en tant que ",
"login": {
"buttonLabel": "Se connecter",
"proconnectButtonLabel": "S'identifier avec ProConnect",
+1 -1
View File
@@ -17,7 +17,7 @@
},
"success": {
"title": "Votre enregistrement est prêt !",
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.",
"body": "Enregistrement de la réunion {{room}} du {{created_at}}.",
"expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).",
"button": "Télécharger",
"warning": {
+11 -1
View File
@@ -156,7 +156,8 @@
"body": "Vous êtes le seul participant. Cet appel va donc se terminer dans {{duration}}. Voulez-vous poursuivre l'appel ?",
"settings": "Pour ne plus voir ce message, accèder à Paramètres > Général.",
"stayButton": "Poursuivre l'appel",
"leaveButton": "Quitter maintenant"
"leaveButton": "Quitter maintenant",
"countdownAnnouncement": "L'appel se termine dans {{duration}}."
},
"controls": {
"microphone": "Microphone",
@@ -504,6 +505,7 @@
"participants": {
"subheading": "Dans la réunion",
"you": "Vous",
"unknown": "Participant inconnu",
"contributors": "Contributeurs",
"host": "Organisateur de la réunion",
"collapsable": {
@@ -551,6 +553,14 @@
"ariaLabel": "Épingler {{name}}"
}
},
"pinAnnouncements": {
"pin": "La vidéo de {{name}} est épinglée.",
"unpin": "La vidéo de {{name}} nest plus épinglée.",
"self": {
"pin": "Votre vidéo est épinglée.",
"unpin": "Votre vidéo nest plus épinglée."
}
},
"recordingStateToast": {
"transcript": {
"started": "Transcription en cours",
+1 -1
View File
@@ -13,7 +13,7 @@
"heading": "U hebt geen toestemming om deze pagina te bekijken"
},
"loading": "Laden ...",
"loggedInUserTooltip": "Ingelogd als ...",
"loggedInUserTooltip": "Ingelogd als ",
"login": {
"buttonLabel": "Log in",
"proconnectButtonLabel": "Log in met Proconnect",
+1 -1
View File
@@ -17,7 +17,7 @@
},
"success": {
"title": "Je opname is klaar!",
"body": "Opname van de vergadering <b>{{room}}</b> op {{created_at}}.",
"body": "Opname van de vergadering {{room}} op {{created_at}}.",
"expiration": "Let op, deze opname verloopt na {{expiration_days}} dag(en).",
"button": "Downloaden",
"warning": {
+11 -1
View File
@@ -156,7 +156,8 @@
"body": "Je bent de enige deelnemer. Dit gesprek wordt over {{duration}} beëindigd. Wil je het gesprek voortzetten?",
"settings": "Om dit bericht niet meer te zien, ga naar Instellingen > Algemeen.",
"stayButton": "Gesprek voortzetten",
"leaveButton": "Nu verlaten"
"leaveButton": "Nu verlaten",
"countdownAnnouncement": "Het gesprek eindigt over {{duration}}."
},
"controls": {
"microphone": "Microfoon",
@@ -504,6 +505,7 @@
"participants": {
"subheading": "In de ruimte",
"you": "U",
"unknown": "Onbekende deelnemer",
"host": "Host",
"contributors": "Deelnemers",
"collapsable": {
@@ -551,6 +553,14 @@
"ariaLabel": "Maak {{name}} vast"
}
},
"pinAnnouncements": {
"pin": "De video van {{name}} is vastgezet.",
"unpin": "De video van {{name}} is niet meer vastgezet.",
"self": {
"pin": "Uw video is vastgezet.",
"unpin": "Uw video is niet meer vastgezet."
}
},
"recordingStateToast": {
"transcript": {
"started": "Transcriptie bezig",
@@ -0,0 +1,29 @@
import { useSnapshot } from 'valtio'
import {
screenReaderAnnouncerStore,
type ScreenReaderChannel,
} from '@/stores/screenReaderAnnouncer'
export const ScreenReaderAnnouncer = ({
channel = 'global',
}: {
channel?: ScreenReaderChannel
}) => {
const { announcements } = useSnapshot(screenReaderAnnouncerStore)
const announcement = announcements[channel]
if (!announcement) return null
return (
<div
role="status"
aria-live={announcement.politeness}
aria-atomic="true"
className="sr-only"
data-announce-id={announcement.id}
data-announce-channel={channel}
>
{announcement.message}
</div>
)
}
@@ -1,10 +1,18 @@
import { type ReactNode, useRef, useState } from 'react'
import {
type ReactElement,
cloneElement,
isValidElement,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css'
export type VisualOnlyTooltipProps = {
children: ReactNode
children: ReactElement
tooltip: string
ariaLabel?: string
tooltipPosition?: 'top' | 'bottom'
}
/**
@@ -20,32 +28,50 @@ export type VisualOnlyTooltipProps = {
export const VisualOnlyTooltip = ({
children,
tooltip,
ariaLabel,
tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => {
const wrapperRef = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{
top: number
left: number
} | null>(null)
const getPosition = () => {
if (!wrapperRef.current) return null
const isBottom = tooltipPosition === 'bottom'
const showTooltip = () => {
if (!wrapperRef.current) return
const rect = wrapperRef.current.getBoundingClientRect()
return {
top: rect.top - 8,
setPosition({
top: isBottom ? rect.bottom + 8 : rect.top - 8,
left: rect.left + rect.width / 2,
}
})
setIsVisible(true)
}
const hideTooltip = () => {
setIsVisible(false)
setPosition(null)
}
const position = getPosition()
const tooltipData = isVisible && position ? { isVisible, position } : null
const wrappedChild = isValidElement(children)
? cloneElement(children, {
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
})
: children
return (
<>
<div
ref={wrapperRef}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
>
{children}
{wrappedChild}
</div>
{tooltipData &&
createPortal(
@@ -66,17 +92,26 @@ export const VisualOnlyTooltip = ({
'&::after': {
content: '""',
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
border: '4px solid transparent',
borderTopColor: 'primaryDark.100',
...(isBottom
? {
bottom: '100%',
borderBottomColor: 'primaryDark.100',
}
: {
top: '100%',
borderTopColor: 'primaryDark.100',
}),
},
})}
style={{
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: 'translate(-50%, -100%)',
transform: isBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)',
}}
>
{tooltip}
+1
View File
@@ -24,6 +24,7 @@ export { Menu } from './Menu'
export { MenuList } from './MenuList'
export { P } from './P'
export { Popover } from './Popover'
export { ScreenReaderAnnouncer } from './ScreenReaderAnnouncer'
export { Text } from './Text'
export { ToggleButton } from './ToggleButton'
export { Ul } from './Ul'
@@ -0,0 +1,86 @@
import { proxy } from 'valtio'
export type Politeness = 'polite' | 'assertive'
export type ScreenReaderChannel = 'global' | 'idle'
type ScreenReaderAnnouncement = {
message: string
politeness: Politeness
id: number
}
type ScreenReaderAnnouncerState = {
announcements: Record<ScreenReaderChannel, ScreenReaderAnnouncement>
}
export const screenReaderAnnouncerStore = proxy<ScreenReaderAnnouncerState>({
announcements: {
global: {
message: '',
politeness: 'polite',
id: 0,
},
idle: {
message: '',
politeness: 'polite',
id: 0,
},
},
})
const channels: ScreenReaderChannel[] = ['global', 'idle']
const announcementTokens: Record<ScreenReaderChannel, number> = {
global: 0,
idle: 0,
}
const announcementTimers: Record<
ScreenReaderChannel,
ReturnType<typeof setTimeout> | null
> = {
global: null,
idle: null,
}
const lastAnnouncementTimes: Record<ScreenReaderChannel, number> = {
global: 0,
idle: 0,
}
const MIN_ANNOUNCEMENT_INTERVAL = 300 // Minimum 300ms between announcements
export const announceToScreenReader = (
message: string,
politeness: Politeness = 'polite',
channel: ScreenReaderChannel = 'global'
) => {
if (!channels.includes(channel)) return
const now = Date.now()
const timeSinceLastAnnouncement = now - lastAnnouncementTimes[channel]
announcementTokens[channel] += 1
const currentToken = announcementTokens[channel]
if (announcementTimers[channel]) {
clearTimeout(announcementTimers[channel]!)
}
const delay = Math.max(
150, // Minimum delay for clear + set sequence
MIN_ANNOUNCEMENT_INTERVAL - timeSinceLastAnnouncement
)
screenReaderAnnouncerStore.announcements[channel] = {
message: '',
politeness,
id: currentToken,
}
announcementTimers[channel] = setTimeout(() => {
if (currentToken !== announcementTokens[channel]) return
screenReaderAnnouncerStore.announcements[channel] = {
message,
politeness,
id: currentToken,
}
lastAnnouncementTimes[channel] = Date.now()
}, delay)
}
+4
View File
@@ -45,6 +45,10 @@ body:has(.lk-video-conference) #crisp-chatbox > * > div[role='button'] {
display: none !important;
}
html:has(.lk-video-conference) {
scrollbar-gutter: auto !important;
}
@keyframes slide-full {
from {
transform: translateY(100%);
@@ -75,11 +75,6 @@ backend:
ROOM_TELEPHONY_PHONE_NUMBER: '+33901020304'
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
ROOM_SUBTITLE_ENABLED: True
EXTERNAL_API_ENABLED: True
APPLICATION_JWT_AUDIENCE: https://meet.127.0.0.1.nip.io/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY: devKeyApplication
APPLICATION_BASE_URL: https://meet.127.0.0.1.nip.io
ADDONS_JWT_SECRET_KEY: devKeyApplicationAddons
migrate:
-28
View File
@@ -88,20 +88,6 @@ spec:
serviceName: {{ include "meet.backend.fullname" . }}
servicePort: {{ .Values.backend.service.port }}
{{- end }}
- path: /addons/
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "meet.backend.fullname" . }}
port:
number: {{ .Values.backend.service.port }}
{{- else }}
serviceName: {{ include "meet.backend.fullname" . }}
servicePort: {{ .Values.backend.service.port }}
{{- end }}
{{- with .Values.ingress.customBackends }}
{{- toYaml . | nindent 10 }}
{{- end }}
@@ -152,20 +138,6 @@ spec:
serviceName: {{ include "meet.backend.fullname" $ }}
servicePort: {{ $.Values.backend.service.port }}
{{- end }}
- path: /addons/
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "meet.backend.fullname" $ }}
port:
number: {{ $.Values.backend.service.port }}
{{- else }}
serviceName: {{ include "meet.backend.fullname" $ }}
servicePort: {{ $.Values.backend.service.port }}
{{- end }}
{{- with $.Values.ingress.customBackends }}
{{- toYaml . | nindent 10 }}
{{- end }}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.5.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.5.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.3.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.3.0",
"version": "1.5.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.3.0",
"version": "1.5.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.3.0"
version = "1.5.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",