Compare commits

..

1 Commits

Author SHA1 Message Date
Cyril 829ef7ed1b ️(a11y) improve settings dialog screen reader support
Add aria labels, initial focus and accessible heading to dialog
2026-02-24 15:45:32 +01:00
49 changed files with 1168 additions and 1751 deletions
+33 -33
View File
@@ -43,12 +43,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
@@ -86,12 +86,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: '-f src/frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -130,12 +130,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: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -174,13 +174,13 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/summary/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
@@ -220,14 +220,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/agents/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
# docker-context: './src/agents'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
-29
View File
@@ -1,29 +0,0 @@
# /!\
# Security Note: This action is not hardened against prompt injection attacks and should only be used
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
# to ensure workflows only run after a maintainer has reviewed the PR.
name: Security Review
permissions:
pull-requests: write # Needed for leaving PR comments
contents: read
on:
pull_request:
branches:
- 'main'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 2
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
with:
comment-pr: true
exclude-directories: docs,gitlint,LICENSES,bin
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
+1 -12
View File
@@ -8,12 +8,9 @@ and this project adheres to
## [Unreleased]
## [1.9.0] - 2026-03-02
### Added
- 👷(docker) add arm64 platform support for image builds
- ✨(summary) add localization support for transcription context text
### Changed
@@ -24,15 +21,8 @@ and this project adheres to
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
- 🌐(frontend) localize SR modifier labels #1010
- ⬆️(backend) update python dependencies #1011
- ♿️(frontend) fix focus ring on tab container components #1012
- ♿️(frontend) upgrade join meeting modal accessibility #1027
- ⬆️(python) bump minimal required python version to 3.13 #1033
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
- ♿️(frontend) add skip link component for keyboard navigation #1019
- ♿️(a11y) fix focus ring on tab container components
### Fixed
- 🩹(frontend) fix German language preference update #1021
## [1.8.0] - 2026-02-20
@@ -57,7 +47,6 @@ and this project adheres to
### Changed
- ✨(frontend) add clickable settings general link in idle modal #974
- ♻️(backend) refactor external API token-related items #1006
## [1.6.0] - 2026-02-10
+4 -6
View File
@@ -23,10 +23,9 @@
# ==============================================================================
# VARIABLES
ESC := $(shell printf '\033')
BOLD := $(ESC)[1m
RESET := $(ESC)[0m
GREEN := $(ESC)[1;32m
BOLD := \033[1m
RESET := \033[0m
GREEN := \033[1;32m
# -- Database
@@ -86,8 +85,7 @@ bootstrap: \
demo \
back-i18n-compile \
mails-install \
mails-build \
run
mails-build
.PHONY: bootstrap
# -- Docker/compose
-1
View File
@@ -2,7 +2,6 @@
Gitlint extra rule to validate that the message title is of the form
"<gitmoji>(<scope>) <subject>"
"""
from __future__ import unicode_literals
import re
-21
View File
@@ -3,21 +3,6 @@
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
{
"groupName": "js dependencies",
"matchManagers": ["npm"],
"schedule": ["on the first day of the month"],
"matchPackagePatterns": ["*"],
"minimumReleaseAge": "7 days",
"internalChecksFilter": "strict"
},
{
"groupName": "python dependencies",
"matchManagers": ["setup-cfg", "pep621"],
"schedule": ["on the first day of the month"],
"matchPackagePatterns": ["*"],
"minimumReleaseAge": "7 days"
},
{
"enabled": false,
"groupName": "ignored python dependencies",
@@ -30,12 +15,6 @@
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.9.0"
version = "1.8.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
+47 -132
View File
@@ -1,51 +1,27 @@
"""Authentication Backends for external application to the Meet core app."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import jwt as pyJwt
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from rest_framework import authentication, exceptions
from core.models import Application
from core.services import jwt_token
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.
def __init__(
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
):
"""Initialize the JWT authentication backend with the given token service configuration.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm (e.g. HS256)
issuer: Expected token issuer identifier
audience: Expected token audience identifier
expiration_seconds: Token expiration time in seconds
token_type: Token type (e.g. Bearer)
"""
super().__init__()
self._token_service = jwt_token.JwtTokenService(
secret_key=secret_key,
algorithm=algorithm,
issuer=issuer,
audience=audience,
expiration_seconds=expiration_seconds,
token_type=token_type,
)
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.
@@ -72,78 +48,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 = self._token_service.decode_jwt(token)
return payload
except jwt_token.TokenExpiredError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except jwt_token.TokenInvalidError as e:
logger.warning("Invalid JWT issuer or audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except jwt_token.TokenDecodeError:
# 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.
@@ -158,41 +62,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.
"""
def __init__(self):
"""Initialize authentication backend with application JWT settings from Django settings."""
super().__init__(
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,
)
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.")
@@ -213,6 +112,22 @@ 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
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return (user, payload)
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."""
+23 -17
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 import JwtTokenService
from . import authentication, permissions, serializers
@@ -127,28 +128,33 @@ class ApplicationViewSet(viewsets.ViewSet):
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
token_service = JwtTokenService(
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_jwt(
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,
)
@@ -167,7 +167,6 @@ class NotificationService:
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
}
headers = {
-153
View File
@@ -1,153 +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 JWTError(Exception):
"""Base exception for all JWT token errors."""
class TokenExpiredError(JWTError):
"""Raised when the JWT token has expired."""
class TokenInvalidError(JWTError):
"""Raised when the JWT token has an invalid issuer or audience."""
class TokenDecodeError(JWTError):
"""Raised for any other unrecoverable JWT decode failure."""
class JwtTokenService:
"""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
issuer: Token issuer identifier
audience: Token audience identifier
expiration_seconds: Token expiration time in seconds
token_type: Token type
Raises:
ImproperlyConfigured: If secret_key is None or empty
"""
if not secret_key:
raise ImproperlyConfigured("Secret key is required.")
if not algorithm:
raise ImproperlyConfigured("Algorithm is required.")
if not token_type:
raise ImproperlyConfigured("Token's type is required.")
if expiration_seconds is None:
raise ImproperlyConfigured("Expiration's seconds is required.")
self._key = secret_key
self._algorithm = algorithm
self._issuer = issuer
self._audience = audience
self._expiration_seconds = expiration_seconds
self._token_type = token_type
def generate_jwt(
self, user, scope: str, extra_payload: Optional[dict] = None
) -> dict:
"""
Generate an access token for the given user.
Note: any extra_payload variables named iat, exp, or user_id will
be overwritten by this service
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 optionally
"""
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._algorithm,
)
response = {
"access_token": token,
"token_type": self._token_type,
"expires_in": self._expiration_seconds,
}
if scope:
response["scope"] = scope
return response
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict.
Raises:
TokenExpiredError: If the token has expired.
TokenInvalidError: If the token has an invalid issuer or audience.
TokenDecodeError: If the token is malformed or cannot be decoded.
"""
try:
payload = jwt.decode(
token,
self._key,
algorithms=[self._algorithm],
issuer=self._issuer,
audience=self._audience,
)
return payload
except jwt.ExpiredSignatureError as e:
raise TokenExpiredError("Token expired.") from e
except (jwt.InvalidIssuerError, jwt.InvalidAudienceError) as e:
raise TokenInvalidError("Invalid token.") from e
except jwt.InvalidTokenError as e:
raise TokenDecodeError("Token decode error.") from e
@@ -55,8 +55,9 @@ def test_api_rooms_list_requires_authentication():
assert response.status_code == 401
def test_api_rooms_list_inactive_user():
def test_api_rooms_list_inactive_user(settings):
"""List should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False)
RoomFactory(users=[(user1, RoleChoices.OWNER)])
@@ -71,9 +72,11 @@ def test_api_rooms_list_inactive_user():
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_list_with_valid_token():
def test_api_rooms_list_with_valid_token(settings):
"""Listing rooms with valid token should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
@@ -89,8 +92,9 @@ def test_api_rooms_list_with_valid_token():
assert response.data["results"][0]["id"] == str(room.id)
def test_api_rooms_list_with_no_rooms():
def test_api_rooms_list_with_no_rooms(settings):
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -108,6 +112,7 @@ def test_api_rooms_list_with_no_rooms():
def test_api_rooms_list_with_expired_token(settings):
"""Listing rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
@@ -148,8 +153,9 @@ def test_api_rooms_list_with_invalid_rs_token(settings):
assert response.status_code == 400
def test_api_rooms_list_missing_scope():
def test_api_rooms_list_missing_scope(settings):
"""Listing rooms without required scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -167,8 +173,9 @@ def test_api_rooms_list_missing_scope():
)
def test_api_rooms_list_no_scope():
def test_api_rooms_list_no_scope(settings):
"""Listing rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -183,8 +190,9 @@ def test_api_rooms_list_no_scope():
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_list_filters_by_user():
def test_api_rooms_list_filters_by_user(settings):
"""List should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
user2 = UserFactory()
@@ -209,9 +217,11 @@ def test_api_rooms_list_filters_by_user():
assert str(room2.id) not in returned_ids
def test_api_rooms_retrieve_requires_authentication():
def test_api_rooms_retrieve_requires_authentication(settings):
"""Retrieving rooms without authentication should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
@@ -221,8 +231,9 @@ def test_api_rooms_retrieve_requires_authentication():
assert response.status_code == 401
def test_api_rooms_retrieve_inactive_user():
def test_api_rooms_retrieve_inactive_user(settings):
"""Retrieve should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False)
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
@@ -239,6 +250,7 @@ def test_api_rooms_retrieve_inactive_user():
def test_api_rooms_retrieve_with_expired_token(settings):
"""Retrieving rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
@@ -283,8 +295,9 @@ def test_api_rooms_retrieve_with_invalid_rs_token(settings):
assert response.status_code == 400
def test_api_rooms_retrieve_requires_scope():
def test_api_rooms_retrieve_requires_scope(settings):
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
@@ -302,8 +315,9 @@ def test_api_rooms_retrieve_requires_scope():
)
def test_api_rooms_retrieve_no_scope():
def test_api_rooms_retrieve_no_scope(settings):
"""Retrieving rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -321,6 +335,7 @@ def test_api_rooms_retrieve_no_scope():
def test_api_rooms_retrieve_success(settings):
"""Retrieving a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = "http://your-application.com"
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
@@ -352,8 +367,9 @@ def test_api_rooms_retrieve_success(settings):
}
def test_api_rooms_retrieve_success_by_user():
def test_api_rooms_retrieve_success_by_user(settings):
"""Retrieve should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
user2 = UserFactory()
@@ -392,8 +408,9 @@ def test_api_rooms_retrieve_success_by_user():
assert response.status_code == 200
def test_api_rooms_retrieve_not_found():
def test_api_rooms_retrieve_not_found(settings):
"""Retrieving a non-existing room with correct scope should return a 404."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
@@ -406,9 +423,11 @@ def test_api_rooms_retrieve_not_found():
assert "no room matches the given query." in str(response.data).lower()
def test_api_rooms_create_requires_authentication():
def test_api_rooms_create_requires_authentication(settings):
"""Creating rooms without authentication should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
client = APIClient()
response = client.post("/external-api/v1.0/rooms/")
@@ -417,6 +436,7 @@ def test_api_rooms_create_requires_authentication():
def test_api_rooms_create_with_expired_token(settings):
"""Creating rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
@@ -457,8 +477,9 @@ def test_api_rooms_create_with_invalid_rs_token(settings):
assert response.status_code == 400
def test_api_rooms_create_inactive_user():
def test_api_rooms_create_inactive_user(settings):
"""Create should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False)
@@ -472,8 +493,9 @@ def test_api_rooms_create_inactive_user():
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_create_requires_scope():
def test_api_rooms_create_requires_scope(settings):
"""Creating a room requires ROOMS_CREATE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without ROOMS_CREATE scope
@@ -490,8 +512,9 @@ def test_api_rooms_create_requires_scope():
)
def test_api_rooms_create_no_scope():
def test_api_rooms_create_no_scope(settings):
"""Creating rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -506,8 +529,9 @@ def test_api_rooms_create_no_scope():
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_create_success():
def test_api_rooms_create_success(settings):
"""Creating a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -531,8 +555,9 @@ def test_api_rooms_create_success():
assert room.access_level == "trusted"
def test_api_rooms_create_readonly_enforcement():
def test_api_rooms_create_readonly_enforcement(settings):
"""Creating a room succeeds and any provided read-only fields are ignored."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -565,8 +590,9 @@ def test_api_rooms_create_readonly_enforcement():
assert room.access_level == "trusted"
def test_api_rooms_unknown_actions():
def test_api_rooms_unknown_actions(settings):
"""Updating or deleting a room are not supported yet."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
@@ -597,6 +623,7 @@ def test_api_rooms_unknown_actions():
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = None
user = UserFactory()
@@ -615,6 +642,7 @@ def test_api_rooms_response_no_url(settings):
def test_api_rooms_response_no_telephony(settings):
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.ROOM_TELEPHONY_ENABLED = False
user = UserFactory()
@@ -633,6 +661,7 @@ def test_api_rooms_response_no_telephony(settings):
def test_api_rooms_token_scope_case_insensitive(settings):
"""Token's scope should be case-insensitive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
@@ -664,6 +693,7 @@ def test_api_rooms_token_scope_case_insensitive(settings):
def test_api_rooms_token_without_delegated_flag(settings):
"""Token without delegated flag should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
@@ -696,6 +726,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
"""Token signed with an invalid key should defer to the next authentication."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
@@ -728,6 +759,7 @@ def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
"""Token signed with an invalid alg should defer to the next authentication."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_ALG = "RS256"
user = UserFactory()
@@ -759,6 +791,7 @@ def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
def test_api_rooms_token_missing_client_id(settings):
"""Token without client_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
now = datetime.now(timezone.utc)
@@ -788,6 +821,7 @@ def test_api_rooms_token_missing_client_id(settings):
def test_api_rooms_token_missing_user_id(settings):
"""Token without user_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
application = ApplicationFactory()
now = datetime.now(timezone.utc)
@@ -817,6 +851,7 @@ def test_api_rooms_token_missing_user_id(settings):
def test_api_rooms_token_invalid_audience(settings):
"""Token with an invalid audience should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
@@ -847,6 +882,7 @@ def test_api_rooms_token_invalid_audience(settings):
def test_api_rooms_token_unknown_user(settings):
"""Token for unknown user should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
application = ApplicationFactory()
now = datetime.now(timezone.utc)
@@ -876,6 +912,7 @@ def test_api_rooms_token_unknown_user(settings):
def test_api_rooms_token_unknown_application(settings):
"""Token for unknown application should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
now = datetime.now(timezone.utc)
payload = {
@@ -904,6 +941,7 @@ def test_api_rooms_token_unknown_application(settings):
def test_api_rooms_token_inactive_application(settings):
"""Token for inactive application should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
application = ApplicationFactory(active=False)
now = datetime.now(timezone.utc)
@@ -21,6 +21,7 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
UserFactory(email="User.Family@example.com")
application = ApplicationFactory(
active=True,
@@ -172,8 +173,9 @@ def test_api_applications_generate_token_domain_not_authorized():
assert "not authorized for this email domain" in str(response.data)
def test_api_applications_generate_token_domain_authorized():
def test_api_applications_generate_token_domain_authorized(settings):
"""Application with domain authorization should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@allowed.com")
application = ApplicationFactory(
active=True,
@@ -228,6 +230,7 @@ def test_api_applications_generate_token_user_not_found():
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
@@ -277,6 +280,7 @@ def test_api_applications_token_payload_structure(settings):
def test_api_applications_token_new_user(settings):
"""Should create a new pending user when creation is allowed and user doesn't exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
@@ -333,6 +337,7 @@ def test_api_applications_token_new_user(settings):
def test_api_applications_token_existing_user(settings):
"""Application should not create a new user when user exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
settings.APPLICATION_ALLOW_USER_CREATION = True
-3
View File
@@ -917,9 +917,6 @@ class Test(Base):
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
def __init__(self):
+3 -3
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.9.0"
version = "1.8.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,13 +17,13 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.10",
]
description = "A simple video and phone conferencing tool, powered by LiveKit"
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.13"
requires-python = ">=3.10"
dependencies = [
"boto3==1.42.49",
"Brotli==1.2.0",
+839 -856
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.9.0",
"version": "1.8.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -36,7 +36,7 @@
"livekit-client": "2.17.1",
"posthog-js": "1.342.1",
"react": "18.3.1",
"react-aria-components": "1.14.0",
"react-aria-components": "1.10.1",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
@@ -4,7 +4,6 @@ import { Button } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const Heading = styled('h2', {
base: {
@@ -145,21 +144,6 @@ type Slide = {
isAvailableInBeta?: boolean
}
const carouselNavButton = css({
_focusVisible: {
outline: '2px solid var(--colors-focus-ring) !important',
outlineOffset: '1px',
},
_disabled: {
color: 'greyscale.400',
cursor: 'default',
pointerEvents: 'none',
_pressed: {
backgroundColor: 'transparent',
},
},
})
// todo - optimize how images are imported
const SLIDES: Slide[] = [
{
@@ -179,45 +163,11 @@ const SLIDES: Slide[] = [
export const IntroSlider = () => {
const [slideIndex, setSlideIndex] = useState(0)
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
const announce = useScreenReaderAnnounce()
const NUMBER_SLIDES = SLIDES.length
const goPrev = () => {
if (slideIndex === 0) return
const newIndex = slideIndex - 1
setSlideIndex(newIndex)
announce(
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
'polite',
'global'
)
}
const goNext = () => {
if (slideIndex === NUMBER_SLIDES - 1) return
const newIndex = slideIndex + 1
setSlideIndex(newIndex)
announce(
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
'polite',
'global'
)
}
const ariaLabelParams = {
current: slideIndex + 1,
total: NUMBER_SLIDES,
}
const previousAriaLabel = t('previous.labelWithPosition', ariaLabelParams)
const nextAriaLabel = t('next.labelWithPosition', ariaLabelParams)
return (
<Container
role="region"
aria-roledescription="carousel"
aria-label={t('carouselLabel')}
>
<Container>
<div
className={css({
display: 'flex',
@@ -230,10 +180,10 @@ export const IntroSlider = () => {
<Button
variant="secondaryText"
square
className={carouselNavButton}
aria-label={previousAriaLabel}
aria-disabled={slideIndex === 0}
onPress={goPrev}
aria-label={t('previous.label')}
tooltip={t('previous.tooltip')}
onPress={() => setSlideIndex(slideIndex - 1)}
isDisabled={slideIndex == 0}
>
<RiArrowLeftSLine />
</Button>
@@ -241,11 +191,7 @@ export const IntroSlider = () => {
</ButtonContainer>
<SlideContainer>
{SLIDES.map((slide, index) => (
<Slide
aria-hidden={index !== slideIndex}
visible={index === slideIndex}
key={index}
>
<Slide visible={index == slideIndex} key={index}>
<Image src={slide.src} alt="" role="presentation" />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
@@ -259,10 +205,10 @@ export const IntroSlider = () => {
<Button
variant="secondaryText"
square
className={carouselNavButton}
aria-label={nextAriaLabel}
aria-disabled={slideIndex === NUMBER_SLIDES - 1}
onPress={goNext}
aria-label={t('next.label')}
tooltip={t('next.tooltip')}
onPress={() => setSlideIndex(slideIndex + 1)}
isDisabled={slideIndex == NUMBER_SLIDES - 1}
>
<RiArrowRightSLine />
</Button>
@@ -5,42 +5,37 @@ import { isRoomValid } from '@/features/rooms'
export const JoinMeetingDialog = () => {
const { t } = useTranslation('home')
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
const roomId = (data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
navigateTo('room', roomId)
}
const validateRoomId = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return null
return !isRoomValid(trimmed) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}
return (
<Dialog title={t('joinMeeting')}>
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
<Form
onSubmit={(data) => {
navigateTo(
'room',
(data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
)
}}
submitLabel={t('joinInputSubmit')}
>
<Field
type="text"
autoFocus
isRequired
name="roomId"
label={t('joinInputLabel')}
description={t('joinInputExample', {
example: window.origin + '/azer-tyu-qsdf',
})}
validate={validateRoomId}
validate={(value) => {
return !isRoomValid(value.trim()) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}}
/>
</Form>
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
@@ -21,7 +21,7 @@ import { AudioTab } from './tabs/AudioTab'
import { VideoTab } from './tabs/VideoTab'
import { TranscriptionTab } from './tabs/TranscriptionTab'
import { ShortcutTab } from './tabs/ShortcutTab'
import { useRef } from 'react'
import { useEffect, useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
@@ -68,6 +68,17 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
const isAdminOrOwner = useIsAdminOrOwner()
useEffect(() => {
if (!props.isOpen) return
const timer = setTimeout(() => {
const selectedTab = dialogEl.current?.querySelector(
'[role="tab"][data-selected]'
) as HTMLElement | null
selectedTab?.focus()
}, 200)
return () => clearTimeout(timer)
}, [props.isOpen])
return (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
<Tabs
@@ -83,46 +94,53 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
paddingRight: !isWideScreen ? '1rem' : undefined,
}}
>
{isWideScreen && (
<Heading slot="title" level={1} className={text({ variant: 'h1' })}>
{t('dialog.heading')}
</Heading>
)}
<TabList border={false}>
<Tab icon highlight id={SettingsDialogExtendedKey.ACCOUNT}>
<RiAccountCircleLine />
<Heading
slot="title"
level={1}
className={
isWideScreen ? text({ variant: 'h1' }) : 'sr-only'
}
>
{t('dialog.heading')}
</Heading>
<TabList
border={false}
aria-label={t('dialog.tablistLabel')}
>
<Tab icon highlight id={SettingsDialogExtendedKey.ACCOUNT} aria-label={t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}>
<RiAccountCircleLine aria-hidden="true" />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.AUDIO}>
<RiSpeakerLine />
<Tab icon highlight id={SettingsDialogExtendedKey.AUDIO} aria-label={t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}>
<RiSpeakerLine aria-hidden="true" />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.VIDEO}>
<RiVideoOnLine />
<Tab icon highlight id={SettingsDialogExtendedKey.VIDEO} aria-label={t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}>
<RiVideoOnLine aria-hidden="true" />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.GENERAL}>
<RiSettings3Line />
<Tab icon highlight id={SettingsDialogExtendedKey.GENERAL} aria-label={t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}>
<RiSettings3Line aria-hidden="true" />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
<RiNotification3Line />
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS} aria-label={t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}>
<RiNotification3Line aria-hidden="true" />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.SHORTCUTS}>
<RiKeyboardBoxLine />
<Tab icon highlight id={SettingsDialogExtendedKey.SHORTCUTS} aria-label={t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}>
<RiKeyboardBoxLine aria-hidden="true" />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION} aria-label={t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}>
<Icon type="symbols" name="speech_to_text" />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
)}
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
<RiEyeLine />
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY} aria-label={t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}>
<RiEyeLine aria-hidden="true" />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
</Tab>
-3
View File
@@ -5,7 +5,6 @@ import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio'
import { Footer } from '@/layout/Footer'
import { ScreenReaderAnnouncer } from '@/primitives'
import { SkipLink, MAIN_CONTENT_ID } from './SkipLink'
export type Layout = 'fullpage' | 'centered'
@@ -22,7 +21,6 @@ export const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
{showHeader && <SkipLink />}
<div
className={css({
display: 'flex',
@@ -37,7 +35,6 @@ export const Layout = ({ children }: { children: ReactNode }) => {
>
{showHeader && <Header />}
<main
id={MAIN_CONTENT_ID}
className={css({
flexGrow: 1,
overflow: 'auto',
-69
View File
@@ -1,69 +0,0 @@
import { type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
export const MAIN_CONTENT_ID = 'main-content'
// Visually hidden until focus (not sr-only). Must become visible on focus for keyboard users.
const StyledSkipLink = styled('a', {
base: {
position: 'absolute',
width: '1px',
height: '1px',
margin: '-1px',
padding: 0,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
textDecoration: 'none',
_focusVisible: {
position: 'fixed',
top: '0.5rem',
left: '50%',
transform: 'translateX(-50%)',
width: 'auto',
height: 'auto',
margin: 0,
padding: '0.625rem 1rem',
overflow: 'visible',
clip: 'auto',
whiteSpace: 'normal',
zIndex: 9999,
backgroundColor: 'white',
color: 'primary.800',
fontWeight: 500,
fontSize: '0.875rem',
border: '1px solid',
borderColor: 'primary.800',
borderRadius: 4,
outline: '2px solid',
outlineColor: 'focusRing',
outlineOffset: 2,
},
},
})
export const SkipLink = () => {
const { t } = useTranslation()
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
const main = document.getElementById(MAIN_CONTENT_ID)
if (!main) return
const heading = main.querySelector('h1, h2, h3') as HTMLElement | null
const target = heading ?? main
if (!target.hasAttribute('tabindex')) {
target.setAttribute('tabindex', '-1')
}
target.focus()
}
return (
<StyledSkipLink href={`#${MAIN_CONTENT_ID}`} onClick={handleClick}>
{t('skipLink')}
</StyledSkipLink>
)
}
-1
View File
@@ -52,7 +52,6 @@
"label": "OK"
}
},
"skipLink": "Zum Hauptinhalt springen",
"clipboardContent": {
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
+5 -9
View File
@@ -31,14 +31,12 @@
},
"introSlider": {
"previous": {
"label": "Vorherige Folie",
"labelWithPosition": "Vorherige Folie ({{current}} von {{total}})",
"tooltip": "Vorherige Folie"
"label": "Zurück",
"tooltip": "Zurück"
},
"next": {
"label": "Nächste Folie",
"labelWithPosition": "Nächste Folie ({{current}} von {{total}})",
"tooltip": "Nächste Folie"
"label": "Weiter",
"tooltip": "Weiter"
},
"beta": {
"text": "An der Beta teilnehmen",
@@ -55,8 +53,6 @@
"slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI",
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!"
},
"carouselLabel": "Einführungs-Diashow",
"slidePosition": "Folie {{current}} von {{total}}"
}
}
}
-1
View File
@@ -52,7 +52,6 @@
"label": "OK"
}
},
"skipLink": "Skip to main content",
"clipboardContent": {
"url": "To join the video conference, click on this link: {{roomUrl}}",
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
+5 -9
View File
@@ -31,14 +31,12 @@
},
"introSlider": {
"previous": {
"label": "Previous slide",
"labelWithPosition": "Previous slide ({{current}} of {{total}})",
"tooltip": "Previous slide"
"label": "previous",
"tooltip": "previous"
},
"next": {
"label": "Next slide",
"labelWithPosition": "Next slide ({{current}} of {{total}})",
"tooltip": "Next slide"
"label": "next",
"tooltip": "next"
},
"beta": {
"text": "Join the beta",
@@ -55,8 +53,6 @@
"slide3": {
"title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
},
"carouselLabel": "Introduction slideshow",
"slidePosition": "Slide {{current}} of {{total}}"
}
}
}
+2 -1
View File
@@ -106,7 +106,8 @@
"columnShortcut": "Shortcut"
},
"dialog": {
"heading": "Settings"
"heading": "Settings",
"tablistLabel": "Sections"
},
"language": {
"heading": "Language",
-1
View File
@@ -52,7 +52,6 @@
"label": "OK"
}
},
"skipLink": "Aller au contenu principal",
"clipboardContent": {
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"
+4 -8
View File
@@ -30,18 +30,14 @@
}
},
"introSlider": {
"carouselLabel": "Diaporama de présentation",
"previous": {
"label": "Diapositive précédente",
"labelWithPosition": "Diapositive précédente ({{current}} sur {{total}})",
"tooltip": "Diapositive précédente"
"label": "précédent",
"tooltip": "précédent"
},
"next": {
"label": "Diapositive suivante",
"labelWithPosition": "Diapositive suivante ({{current}} sur {{total}})",
"tooltip": "Diapositive suivante"
"label": "suivant",
"tooltip": "suivant"
},
"slidePosition": "Diapositive {{current}} sur {{total}}",
"beta": {
"text": "Essayer la beta",
"tooltip": "Accéder au formulaire"
+2 -1
View File
@@ -106,7 +106,8 @@
"columnShortcut": "Raccourci"
},
"dialog": {
"heading": "Paramètres"
"heading": "Paramètres",
"tablistLabel": "Sections"
},
"language": {
"heading": "Langue",
-1
View File
@@ -51,7 +51,6 @@
"label": "OK"
}
},
"skipLink": "Naar de hoofdinhoud gaan",
"clipboardContent": {
"url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}",
"numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}"
+5 -9
View File
@@ -31,14 +31,12 @@
},
"introSlider": {
"previous": {
"label": "Vorige dia",
"labelWithPosition": "Vorige dia ({{current}} van {{total}})",
"tooltip": "Vorige dia"
"label": "vorige",
"tooltip": "vorige"
},
"next": {
"label": "Volgende dia",
"labelWithPosition": "Volgende dia ({{current}} van {{total}})",
"tooltip": "Volgende dia"
"label": "volgende",
"tooltip": "volgende"
},
"beta": {
"text": "Word lid van de bèta",
@@ -55,8 +53,6 @@
"slide3": {
"title": "Transformeer uw vergaderingen met AI",
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
},
"carouselLabel": "Introductie-diavoorstelling",
"slidePosition": "Dia {{current}} van {{total}}"
}
}
}
-5
View File
@@ -22,11 +22,6 @@ body,
outline: 2px solid transparent;
}
main#main-content :is(h1, h2, h3)[tabindex='-1']:focus {
outline: 2px solid var(--colors-focus-ring);
outline-offset: 2px;
}
[data-rac][data-focus-visible]:not(label, .react-aria-Select),
:is(a, button, input[type='text'], select, textarea):not(
[data-rac]
+2 -3
View File
@@ -1,13 +1,12 @@
// Map frontend language codes to backend language codes
export type BackendLanguage = 'en-us' | 'fr-fr' | 'nl-nl' | 'de-de'
export type FrontendLanguage = 'en' | 'fr' | 'nl' | 'de'
export type BackendLanguage = 'en-us' | 'fr-fr' | 'nl-nl'
export type FrontendLanguage = 'en' | 'fr' | 'nl'
const frontendToBackendMap: Record<FrontendLanguage, BackendLanguage> = {
en: 'en-us',
fr: 'fr-fr',
nl: 'nl-nl',
de: 'de-de',
}
export const convertToBackendLanguage = (
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.9.0",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.9.0",
"version": "1.8.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.9.0",
"version": "1.8.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.9.0",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.9.0",
"version": "1.8.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.9.0",
"version": "1.8.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.9.0"
version = "1.8.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
+4 -6
View File
@@ -15,8 +15,8 @@ from summary.core.config import get_settings
settings = get_settings()
class TranscribeSummarizeTaskCreation(BaseModel):
"""Transcription and summarization parameters."""
class TaskCreation(BaseModel):
"""Task data."""
owner_id: str
filename: str
@@ -28,7 +28,6 @@ class TranscribeSummarizeTaskCreation(BaseModel):
recording_time: Optional[str]
language: Optional[str]
download_link: Optional[str]
context_language: Optional[str] = None
@field_validator("language")
@classmethod
@@ -46,8 +45,8 @@ router = APIRouter(prefix="/tasks")
@router.post("/")
async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreation):
"""Create a transcription and summarization task."""
async def create_task(request: TaskCreation):
"""Create a task."""
task = process_audio_transcribe_summarize_v2.apply_async(
args=[
request.owner_id,
@@ -60,7 +59,6 @@ async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreat
request.recording_time,
request.language,
request.download_link,
request.context_language,
],
queue=settings.transcribe_queue,
)
+4 -22
View File
@@ -18,7 +18,6 @@ from summary.core.analytics import MetadataManager, get_analytics
from summary.core.config import get_settings
from summary.core.file_service import FileService, FileServiceException
from summary.core.llm_service import LLMException, LLMObservability, LLMService
from summary.core.locales import get_locale
from summary.core.prompt import (
FORMAT_NEXT_STEPS,
FORMAT_PLAN,
@@ -122,7 +121,6 @@ def process_audio_transcribe_summarize_v2(
recording_time: Optional[str],
language: Optional[str],
download_link: Optional[str],
context_language: Optional[str] = None,
):
"""Process an audio file by transcribing it and generating a summary.
@@ -131,19 +129,6 @@ def process_audio_transcribe_summarize_v2(
2. Transcribes the audio using WhisperX model
3. Sends the results via webhook
Args:
self: Celery task instance (passed on with bind=True)
owner_id: Unique identifier of the recording owner.
filename: Name of the audio file in MinIO storage.
email: Email address of the recording owner.
sub: OIDC subject identifier of the recording owner.
received_at: Unix timestamp when the recording was received.
room: room name where the recording took place.
recording_date: Date of the recording (localized display string).
recording_time: Time of the recording (localized display string).
language: ISO 639-1 language code for transcription.
download_link: URL to download the original recording.
context_language: ISO 639-1 language code of the meeting summary context text.
"""
logger.info(
"Notification received | Owner: %s | Room: %s",
@@ -160,7 +145,6 @@ def process_audio_transcribe_summarize_v2(
max_retries=settings.whisperx_max_retries,
)
# Transcription
try:
with (
file_service.prepare_audio_file(filename) as (audio_file, metadata),
@@ -199,10 +183,7 @@ def process_audio_transcribe_summarize_v2(
metadata_manager.track_transcription_metadata(task_id, transcription)
# For locale of context, use in decreasing priority context_language,
# language (of meeting), default context language
locale = get_locale(context_language, language)
formatter = TranscriptFormatter(locale)
formatter = TranscriptFormatter()
content, title = formatter.format(
transcription,
@@ -240,7 +221,6 @@ def process_audio_transcribe_summarize_v2(
metadata_manager.capture(task_id, settings.posthog_event_success)
# LLM Summarization
if (
analytics.is_feature_enabled("summary-enabled", distinct_id=owner_id)
and settings.is_summary_enabled
@@ -356,7 +336,9 @@ def summarize_transcription(
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
data = {
"title": settings.summary_title_template.format(title=title),
"title": settings.summary_title_template.format(
title=title,
),
"content": summary,
"email": email,
"sub": sub,
+6 -4
View File
@@ -1,7 +1,7 @@
"""Application configuration and settings."""
from functools import lru_cache
from typing import Annotated, List, Literal, Optional, Set
from typing import Annotated, List, Optional, Set
from fastapi import Depends
from pydantic import SecretStr
@@ -51,6 +51,7 @@ class Settings(BaseSettings):
# Transcription processing
hallucination_patterns: List[str] = ["Vap'n'Roll Thierry"]
hallucination_replacement_text: str = "[Texte impossible à transcrire]"
# Webhook-related settings
webhook_max_retries: int = 2
@@ -59,10 +60,11 @@ class Settings(BaseSettings):
webhook_api_token: SecretStr
webhook_url: str
# Locale
default_context_language: Literal["de", "en", "fr", "nl"] = "fr"
# Output related settings
document_default_title: Optional[str] = "Transcription"
document_title_template: Optional[str] = (
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
)
summary_title_template: Optional[str] = "Résumé de {title}"
# Summary related settings
@@ -1,30 +0,0 @@
"""Locale support for the summary service."""
from typing import Optional
from summary.core.config import get_settings
from summary.core.locales import de, en, fr, nl
from summary.core.locales.strings import LocaleStrings
_LOCALES = {"fr": fr, "en": en, "de": de, "nl": nl}
def get_locale(*languages: Optional[str]) -> LocaleStrings:
"""Return locale strings for the first matching language candidate.
Accept language codes in decreasing priority order and return the
locale for the first one that matches a known locale.
Fall back to the configured default_context_language.
"""
for lang in languages:
if not lang:
continue
if lang in _LOCALES:
return _LOCALES[lang].STRINGS
# Provide fallback for longer formats of ISO 639-1 (e.g. "en-au" -> "en")
base_lang = lang.split("-")[0]
if base_lang in _LOCALES:
return _LOCALES[base_lang].STRINGS
return _LOCALES[get_settings().default_context_language].STRINGS
-34
View File
@@ -1,34 +0,0 @@
"""German locale strings."""
from summary.core.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
**In Ihrer Transkription wurde kein Audioinhalt erkannt.**
*Wenn Sie glauben, dass es sich um einen Fehler handelt, zögern Sie nicht,
unseren technischen Support zu kontaktieren: visio@numerique.gouv.fr*
.
.
.
Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
- War ein Mikrofon aktiviert?
- Waren Sie nah genug am Mikrofon?
- Ist das Mikrofon von guter Qualität?
- Dauert die Aufnahme länger als 30 Sekunden?
""",
download_header_template=(
"\n*Laden Sie Ihre Aufnahme herunter, "
"indem Sie [diesem Link folgen]({download_link})*\n"
),
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
document_default_title="Transkription",
document_title_template=(
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
),
)
-33
View File
@@ -1,33 +0,0 @@
"""English locale strings."""
from summary.core.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
**No audio content was detected in your transcription.**
*If you believe this is an error, please do not hesitate to contact
our technical support: visio@numerique.gouv.fr*
.
.
.
A few things we recommend you check:
- Was a microphone enabled?
- Were you close enough to the microphone?
- Is the microphone of good quality?
- Is the recording longer than 30 seconds?
""",
download_header_template=(
"\n*Download your recording by [following this link]({download_link})*\n"
),
hallucination_replacement_text="[Unable to transcribe text]",
document_default_title="Transcription",
document_title_template=(
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
),
)
-33
View File
@@ -1,33 +0,0 @@
"""French locale strings (default)."""
from summary.core.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
**Aucun contenu audio n'a été détecté dans votre transcription.**
*Si vous pensez qu'il s'agit d'une erreur, n'hésitez pas à contacter
notre support technique : visio@numerique.gouv.fr*
.
.
.
Quelques points que nous vous conseillons de vérifier :
- Un micro était-il activé ?
- Étiez-vous suffisamment proche ?
- Le micro est-il de bonne qualité ?
- L'enregistrement dure-t-il plus de 30 secondes ?
""",
download_header_template=(
"\n*Télécharger votre enregistrement en [suivant ce lien]({download_link})*\n"
),
hallucination_replacement_text="[Texte impossible à transcrire]",
document_default_title="Transcription",
document_title_template=(
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
),
)
-33
View File
@@ -1,33 +0,0 @@
"""Dutch locale strings."""
from summary.core.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
**Er is geen audio-inhoud gedetecteerd in uw transcriptie.**
*Als u denkt dat dit een fout is, aarzel dan niet om contact op te nemen
met onze technische ondersteuning: visio@numerique.gouv.fr*
.
.
.
Een paar punten die wij u aanraden te controleren:
- Was er een microfoon ingeschakeld?
- Was u dicht genoeg bij de microfoon?
- Is de microfoon van goede kwaliteit?
- Duurt de opname langer dan 30 seconden?
""",
download_header_template=(
"\n*Download uw opname door [deze link te volgen]({download_link})*\n"
),
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
document_default_title="Transcriptie",
document_title_template=(
'Vergadering "{room}" op {room_recording_date} om {room_recording_time}'
),
)
@@ -1,15 +0,0 @@
"""Locale types for the summary service."""
from dataclasses import dataclass
@dataclass(frozen=True)
class LocaleStrings:
"""All translatable output strings for the summary pipeline."""
# transcript_formatter.py
empty_transcription: str
download_header_template: str
hallucination_replacement_text: str
document_default_title: str
document_title_template: str
@@ -4,13 +4,34 @@ import logging
from typing import Optional, Tuple
from summary.core.config import get_settings
from summary.core.locales import LocaleStrings
settings = get_settings()
logger = logging.getLogger(__name__)
DEFAULT_EMPTY_TRANSCRIPTION = """
**Aucun contenu audio na été détecté dans votre transcription.**
*Si vous pensez quil sagit dune erreur, nhésitez pas à contacter
notre support technique : visio@numerique.gouv.fr*
.
.
.
Quelques points que nous vous conseillons de vérifier :
- Un micro était-il activé ?
- Étiez-vous suffisamment proche ?
- Le micro est-il de bonne qualité ?
- Lenregistrement dure-t-il plus de 30 secondes ?
"""
class TranscriptFormatter:
"""Formats WhisperX transcription output into readable conversation format.
@@ -21,10 +42,12 @@ class TranscriptFormatter:
- Generating descriptive titles from context
"""
def __init__(self, locale: LocaleStrings):
"""Initialize formatter with settings and locale."""
def __init__(self):
"""Initialize formatter with settings."""
self.hallucination_patterns = settings.hallucination_patterns
self._locale = locale
self.hallucination_replacement_text = settings.hallucination_replacement_text
self.default_title = settings.document_default_title
self.default_empty_transcription = DEFAULT_EMPTY_TRANSCRIPTION
def _get_segments(self, transcription):
"""Extract segments from transcription object or dictionary."""
@@ -48,7 +71,7 @@ class TranscriptFormatter:
segments = self._get_segments(transcription)
if not segments:
content = self._locale.empty_transcription
content = self.default_empty_transcription
else:
content = self._format_speaker(segments)
content = self._remove_hallucinations(content)
@@ -60,7 +83,7 @@ class TranscriptFormatter:
def _remove_hallucinations(self, content: str) -> str:
"""Remove hallucination patterns from content."""
replacement = self._locale.hallucination_replacement_text or ""
replacement = self.hallucination_replacement_text or ""
for pattern in self.hallucination_patterns:
content = content.replace(pattern, replacement)
@@ -88,8 +111,9 @@ class TranscriptFormatter:
if not download_link:
return content
header = self._locale.download_header_template.format(
download_link=download_link
header = (
f"\n*Télécharger votre enregistrement "
f"en [suivant ce lien]({download_link})*\n"
)
content = header + content
@@ -103,9 +127,9 @@ class TranscriptFormatter:
) -> str:
"""Generate title from context or return default."""
if not room or not recording_date or not recording_time:
return self._locale.document_default_title
return self.default_title
return self._locale.document_title_template.format(
return settings.document_title_template.format(
room=room,
room_recording_date=recording_date,
room_recording_time=recording_time,