From 724a4ef3dfb3c00109b4e031fa1eabfe148ff645 Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Fri, 20 Mar 2026 18:35:49 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F(backend)=20secure=20nativ?= =?UTF-8?q?e=20app=20OIDC=20login=20with=20exchange=20codes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add one-time exchange code mechanism for native app OIDC login. Instead of exposing session ID in redirect URL, generates a short-lived single-use code stored in Redis. Native apps exchange this code for the session ID via a dedicated API endpoint. Includes NativeAppRedirect for custom URL schemes, rate limiting, logging, and whitelist of allowed schemes. Closes #1153 Co-Authored-By: gigi206 --- .github/workflows/docker-hub.yml | 66 +- .github/workflows/meet.yml | 40 - CHANGELOG.md | 26 +- Makefile | 6 - bin/pytest-summary | 7 - docs/examples/compose/keycloak/README.md | 7 +- env.d/development/common.dist | 2 +- src/agents/Dockerfile | 2 + src/agents/pyproject.toml | 2 +- src/backend/core/api/throttling.py | 6 + src/backend/core/authentication/api.py | 61 ++ src/backend/core/authentication/views.py | 103 ++ .../authentication/test_session_exchange.py | 143 +++ src/backend/core/urls.py | 4 + src/backend/meet/settings.py | 17 +- src/backend/pyproject.toml | 4 +- src/backend/uv.lock | 10 +- src/frontend/package-lock.json | 4 +- src/frontend/package.json | 2 +- .../public/assets/logo-suite-numerique.png | Bin 9120 -> 11214 bytes src/frontend/src/api/fetchApi.ts | 14 +- src/frontend/src/api/queryKeys.ts | 1 - src/frontend/src/api/useConfig.ts | 7 - .../src/features/files/api/createFile.ts | 93 -- .../src/features/files/api/deleteFile.ts | 33 - .../src/features/files/api/listFiles.ts | 70 -- src/frontend/src/features/files/api/types.ts | 34 - .../features/rooms/components/Conference.tsx | 4 +- .../src/features/rooms/components/Join.tsx | 31 +- .../rooms/livekit/components/SidePanel.tsx | 5 +- .../rooms/livekit/components/Tools.tsx | 2 +- .../blur/BackgroundCustomProcessor.ts | 43 +- .../components/blur/FaceLandmarksProcessor.ts | 11 + .../blur/UnifiedBackgroundTrackProcessor.ts | 47 +- .../rooms/livekit/components/blur/index.ts | 45 +- .../controls/Device/VideoDeviceControl.tsx | 4 +- .../livekit/components/effects/Effects.tsx | 5 + .../effects/EffectsConfiguration.tsx | 934 +++++------------- .../components/effects/FunnyEffects.tsx | 2 +- .../livekit/hooks/usePersistentUserChoices.ts | 8 +- .../settings/components/tabs/VideoTab.tsx | 4 +- .../subtitle/component/CaptionsSettings.tsx | 72 +- .../features/subtitle/component/Subtitles.tsx | 14 +- src/frontend/src/locales/de/rooms.json | 49 +- src/frontend/src/locales/de/settings.json | 28 - src/frontend/src/locales/en/rooms.json | 48 +- src/frontend/src/locales/en/settings.json | 28 - src/frontend/src/locales/fr/rooms.json | 48 +- src/frontend/src/locales/fr/settings.json | 28 - src/frontend/src/locales/nl/rooms.json | 46 +- src/frontend/src/locales/nl/settings.json | 28 - src/frontend/src/primitives/Spinner.tsx | 6 +- src/frontend/src/primitives/Text.tsx | 4 - src/frontend/src/stores/accessibility.ts | 68 +- src/frontend/src/stores/userChoices.ts | 37 +- src/frontend/src/styles/index.css | 11 - src/frontend/vite.config.ts | 8 - src/mail/mjml/screen_recording.mjml | 2 +- src/mail/package-lock.json | 4 +- src/mail/package.json | 2 +- src/sdk/package-lock.json | 4 +- src/sdk/package.json | 2 +- src/summary/pyproject.toml | 11 +- src/summary/summary/core/locales/de.py | 3 +- src/summary/summary/core/locales/en.py | 2 +- src/summary/summary/core/locales/fr.py | 2 +- src/summary/summary/core/locales/nl.py | 2 +- src/summary/tests/__init__.py | 1 - src/summary/tests/api/__init__.py | 1 - src/summary/tests/api/test_api_health.py | 21 - src/summary/tests/api/test_api_tasks.py | 88 -- src/summary/tests/conftest.py | 24 - 72 files changed, 846 insertions(+), 1755 deletions(-) delete mode 100755 bin/pytest-summary create mode 100644 src/backend/core/authentication/api.py create mode 100644 src/backend/core/authentication/views.py create mode 100644 src/backend/core/tests/authentication/test_session_exchange.py delete mode 100644 src/frontend/src/features/files/api/createFile.ts delete mode 100644 src/frontend/src/features/files/api/deleteFile.ts delete mode 100644 src/frontend/src/features/files/api/listFiles.ts delete mode 100644 src/frontend/src/features/files/api/types.ts delete mode 100644 src/summary/tests/__init__.py delete mode 100644 src/summary/tests/api/__init__.py delete mode 100644 src/summary/tests/api/test_api_health.py delete mode 100644 src/summary/tests/api/test_api_tasks.py delete mode 100644 src/summary/tests/conftest.py diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index a77c5a17..5871ac90 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -48,12 +48,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 @@ -93,12 +93,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 @@ -139,12 +139,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 @@ -185,13 +185,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 @@ -233,14 +233,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 diff --git a/.github/workflows/meet.yml b/.github/workflows/meet.yml index 78894582..f924c982 100644 --- a/.github/workflows/meet.yml +++ b/.github/workflows/meet.yml @@ -297,46 +297,6 @@ jobs: - name: Run tests run: uv run pytest -n 2 - test-summary: - runs-on: ubuntu-latest - permissions: - contents: read - defaults: - run: - working-directory: src/summary - - env: - APP_API_TOKEN: "test-api-token" - AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage" - AWS_S3_ENDPOINT_URL: "minio:9000" - AWS_S3_ACCESS_KEY_ID: "meet" - AWS_S3_SECRET_ACCESS_KEY: "password" - WHISPERX_BASE_URL: "https://configure-your-url.com" - WHISPERX_ASR_MODEL: "large-v2" - WHISPERX_API_KEY: "test-whisperx-secret" - WHISPERX_DEFAULT_LANGUAGE: "fr" - LLM_BASE_URL: "https://configure-your-url.com" - LLM_API_KEY: "test-llm-secret" - LLM_MODEL: "test-llm-model" - WEBHOOK_API_TOKEN: "test-webhook-secret" - WEBHOOK_URL: "https://configure-your-url.com" - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Install Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - cache: "pip" - - - name: Install development dependencies - run: pip install --user .[dev] - - - name: Run summary tests - run: ~/.local/bin/pytest - lint-front: runs-on: ubuntu-latest permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 18cbbff7..9e2c67f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,23 +8,6 @@ and this project adheres to ## [Unreleased] -### Changed - -- ♿️(frontend) fix sidepanel accessibility aria-label #1182 -- ♿️(frontend) fix more tools heading hierarchy #1181 -- ♿️(fronted) improve button descriptions for More tools actions #1184 -- 💄(spinner) enforce spinner height #1183 -- 💄(custom-background) add upload indicator with preview #1183 -- ♿️(backend) improve logo accessibility in recording email notification #1092 -- ♿️(summary) improve accessibility of transcription download link #1187 - -### Fixed - -- 🐛(frontend) disable personal custom background while deleting #1183 -- 🐛(frontend) auto-select new custom background when not logged in #1183 - -## [1.11.0] - 2026-03-19 - ### Added - ✨(helm) support celery with our Django backend #1124 @@ -32,24 +15,20 @@ and this project adheres to - ✨(backend) add authenticated user rate throttling on request-entry #1129 - ✨(backend) expose `is_active` field for Application in Django admin #1133 - ✨(file-upload) disable by default & limit count by user #1141 -- ✨(frontend) custom background #1067 ### Changed - ♿️(frontend) Caption text size setting for accessibility #1062 - ♿️(frontend) sync html lang attribute with i18n for screen readers #1111 - ♿️(frontend) improve MoreLink a11y and UX on home page #1112 -- ♿️(frontend) improve chat toast a11y for screen readers #1109 -- ♿️(frontend) improve ui and aria labels for help article links #1108 +- ♿(frontend) improve chat toast a11y for screen readers #1109 +- ♿(frontend) improve ui and aria labels for help article links #1108 - 🌐(frontend) improve German translation #1125 - 🔨(python-env) migrate meet main app to UV #1120 - ♻️(backend) align Application model field with `is_active` convention #1133 - 🔐(backend) avoids revealing the inactive status of an application #1135 - ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139 - 🔒️(backend) avoid information exposure through exception messages #1144 -- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151 -- 📌(agents) unpin OpenSSL and related dependencies #1167 -- ♿️(frontend) add caption font and background color customization #1122 ### Fixed @@ -57,7 +36,6 @@ and this project adheres to - 🩹(backend) add page_size to pagination for room endpoints #1131 - 🐛(backend) refactor lobby throttling to use participant id #1129 - 🩹(backend) ignore non-recording uploads in storage webhook handler #1142 -- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116 ## [1.10.0] - 2026-03-05 diff --git a/Makefile b/Makefile index 5c9504d5..b044a718 100644 --- a/Makefile +++ b/Makefile @@ -191,7 +191,6 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f test: ## run project tests @$(MAKE) test-back-parallel - @$(MAKE) test-summary .PHONY: test test-back: ## run back-end tests @@ -204,11 +203,6 @@ test-back-parallel: ## run all back-end tests in parallel bin/pytest -n auto $${args:-${1}} .PHONY: test-back-parallel -test-summary: ## run summary tests - @args="$(filter-out $@,$(MAKECMDGOALS))" && \ - bin/pytest-summary $${args:-${1}} -.PHONY: test-summary - makemigrations: ## run django makemigrations for the Meet project. @echo "$(BOLD)Running makemigrations$(RESET)" @$(COMPOSE) up -d postgresql diff --git a/bin/pytest-summary b/bin/pytest-summary deleted file mode 100755 index 29e32101..00000000 --- a/bin/pytest-summary +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" - -_dc_run \ - app-summary-dev \ - python -m pytest "$@" diff --git a/docs/examples/compose/keycloak/README.md b/docs/examples/compose/keycloak/README.md index 79e8d6f4..dff2d5d6 100644 --- a/docs/examples/compose/keycloak/README.md +++ b/docs/examples/compose/keycloak/README.md @@ -61,10 +61,11 @@ services: `docker compose up -d` ``` -Your keycloak instance is now available on https://id.yourdomain.tld +Your keycloak instance is now available on https://doc.yourdomain.tld > [!CAUTION] > Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags). +``` ## Creating an OIDC Client for Meet Application @@ -75,7 +76,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld 3. Enter the name of the realm - `meet`. 4. Click "Create". -### Step 2: Create a New Client +#### Step 2: Create a New Client 1. Navigate to the "Clients" tab. 2. Click on the "Create client" button. @@ -85,7 +86,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld 1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`. 1. Click "Save". -### Step 3: Get Client Credentials +#### Step 3: Get Client Credentials 1. Go to the "Credentials" tab. 2. Copy the client ID (`meet` in this example) and the client secret. diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 4dcc335c..f3fe3e00 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -27,7 +27,7 @@ AWS_S3_DOMAIN_REPLACE=http://localhost:9000 AWS_S3_ENDPOINT_URL=http://minio:9000 AWS_S3_ACCESS_KEY_ID=meet AWS_S3_SECRET_ACCESS_KEY=password -MEDIA_BASE_URL=http://localhost:3000 +MEDIA_BASE_URL=http://localhost:8083 FILE_UPLOAD_ENABLED=True # OIDC diff --git a/src/agents/Dockerfile b/src/agents/Dockerfile index 50c29007..f6c3c652 100644 --- a/src/agents/Dockerfile +++ b/src/agents/Dockerfile @@ -4,6 +4,8 @@ FROM python:3.13-slim AS base RUN apt-get update && apt-get install -y \ libglib2.0-0 \ libgobject-2.0-0 \ + "openssl=3.5.4-1~deb13u2" \ + "libssl3t64=3.5.4-1~deb13u2" \ && rm -rf /var/lib/apt/lists/* FROM base AS builder diff --git a/src/agents/pyproject.toml b/src/agents/pyproject.toml index d3e033e3..cc00dcf9 100644 --- a/src/agents/pyproject.toml +++ b/src/agents/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "agents" -version = "1.11.0" +version = "1.10.0" requires-python = ">=3.12" dependencies = [ "livekit-agents==1.3.10", diff --git a/src/backend/core/api/throttling.py b/src/backend/core/api/throttling.py index b7b89b43..e0a8e97a 100644 --- a/src/backend/core/api/throttling.py +++ b/src/backend/core/api/throttling.py @@ -73,3 +73,9 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle): """Throttle Anonymous user requesting room generation callback""" scope = "creation_callback" + + +class SessionExchangeAnonRateThrottle(MonitoredAnonRateThrottle): + """Throttle anonymous requests to the session exchange endpoint.""" + + scope = "session_exchange" diff --git a/src/backend/core/authentication/api.py b/src/backend/core/authentication/api.py new file mode 100644 index 00000000..5446689d --- /dev/null +++ b/src/backend/core/authentication/api.py @@ -0,0 +1,61 @@ +"""API endpoint for exchanging a one-time code for a session ID.""" + +import logging + +from django.conf import settings +from django.core.cache import cache + +from rest_framework import serializers, status +from rest_framework.decorators import api_view, permission_classes, throttle_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from core.api.throttling import SessionExchangeAnonRateThrottle + +from .views import EXCHANGE_CODE_PREFIX + +logger = logging.getLogger(__name__) + + +class SessionExchangeSerializer(serializers.Serializer): + """Validates the exchange code request.""" + + code = serializers.CharField(max_length=64, min_length=16) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +@throttle_classes([SessionExchangeAnonRateThrottle]) +def session_exchange( + request, +): # NOSONAR (S3752) POST-only, AllowAny is intentional: single-use code with 30s TTL and rate limiting + """Exchange a one-time code for a session ID. + + The code was generated during the OIDC callback and stored in cache + with a short TTL. This endpoint retrieves the session ID, deletes the + code (single-use), and returns the session ID to the native app. + """ + serializer = SessionExchangeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + code = serializer.validated_data["code"] + cache_key = f"{EXCHANGE_CODE_PREFIX}{code}" + + session_key = cache.get(cache_key) + if session_key is None: + logger.warning( + "Session exchange failed: invalid or expired code from %s", + request.META.get("REMOTE_ADDR"), + ) + return Response( + {"detail": "Invalid or expired code."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Delete immediately — single use + cache.delete(cache_key) + + cookie_name = getattr(settings, "SESSION_COOKIE_NAME", "sessionid") + logger.info("Session exchange successful from %s", request.META.get("REMOTE_ADDR")) + + return Response({cookie_name: session_key}) diff --git a/src/backend/core/authentication/views.py b/src/backend/core/authentication/views.py new file mode 100644 index 00000000..4dfa62d3 --- /dev/null +++ b/src/backend/core/authentication/views.py @@ -0,0 +1,103 @@ +"""Custom OIDC authentication views for native app support. + +When a native app (iOS, Android, Desktop) initiates OIDC login, it sets +`returnTo` to a custom URL scheme (e.g. `visio://auth-callback`). After +the OIDC flow completes, Django sets the session cookie and redirects to +that URL. However, native apps cannot read browser cookies — they need +the session ID passed explicitly. + +Instead of exposing the session ID directly in the redirect URL, this +module generates a short-lived, single-use exchange code. The native app +then exchanges this code for the session ID via a dedicated API endpoint. +""" + +import uuid +from urllib.parse import urlencode, urlparse + +from django.conf import settings +from django.core.cache import cache +from django.http import HttpResponseRedirect + +from lasuite.oidc_login.views import ( + OIDCAuthenticationCallbackView as BaseCallbackView, + OIDCAuthenticationRequestView as BaseRequestView, +) + +# Cache key prefix and TTL for exchange codes +EXCHANGE_CODE_PREFIX = "auth_exchange:" +EXCHANGE_CODE_TTL = 30 # seconds + + +class NativeAppRedirect(HttpResponseRedirect): + """HttpResponseRedirect subclass that allows native app custom URL schemes. + + Django's HttpResponseRedirect only allows http, https, and ftp schemes. + Native apps use custom schemes (e.g. visio://) for deep links, which + Django rejects with DisallowedRedirect. This subclass extends + allowed_schemes with the configured native app schemes. + """ + + allowed_schemes = HttpResponseRedirect.allowed_schemes + list( + getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", []) + ) + + +class OIDCAuthenticationRequestView(BaseRequestView): + """Custom authenticate view that preserves native app returnTo in session. + + mozilla-django-oidc's get_next_url() rejects custom URL schemes + (e.g. visio://auth-callback) because url_has_allowed_host_and_scheme() + only allows http/https. We intercept the returnTo parameter and store + it directly in the session for whitelisted schemes, bypassing the + safety check (which is not relevant for native app deep links). + """ + + def get(self, request): + redirect_field = getattr(settings, "OIDC_REDIRECT_FIELD_NAME", "returnTo") + return_to = request.GET.get(redirect_field, "") + parsed = urlparse(return_to) + allowed_schemes = getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", []) + + response = super().get(request) + + # Override oidc_login_next AFTER super() which set it to None + # via get_next_url() rejecting the custom scheme. + if parsed.scheme in allowed_schemes: + request.session["oidc_login_next"] = return_to + request.session.save() + + return response + + +class OIDCAuthenticationCallbackView(BaseCallbackView): + """Callback view that generates an exchange code for native app deep links.""" + + def login_success(self): + """After successful login, append exchange code for whitelisted scheme redirects.""" + # Temporarily remove native redirect from session to prevent + # super().login_success() from raising DisallowedRedirect when + # it tries HttpResponseRedirect with a custom scheme. + native_redirect = self.request.session.pop("oidc_login_next", None) + allowed_schemes = getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", []) + parsed = urlparse(native_redirect or "") + + if native_redirect and parsed.scheme in allowed_schemes: + # Let super() redirect to the default URL (homepage) + super().login_success() + + # Generate a short-lived, single-use exchange code + exchange_code = uuid.uuid4().hex + session_key = self.request.session.session_key + cache.set( + f"{EXCHANGE_CODE_PREFIX}{exchange_code}", + session_key, + EXCHANGE_CODE_TTL, + ) + + separator = "&" if parsed.query else "?" + new_url = ( + f"{native_redirect}{separator}{urlencode({'code': exchange_code})}" + ) + return NativeAppRedirect(new_url) + + return super().login_success() diff --git a/src/backend/core/tests/authentication/test_session_exchange.py b/src/backend/core/tests/authentication/test_session_exchange.py new file mode 100644 index 00000000..e4bdb9db --- /dev/null +++ b/src/backend/core/tests/authentication/test_session_exchange.py @@ -0,0 +1,143 @@ +""" +Tests for the session exchange API endpoint and OIDC callback view. +""" + +import uuid + +from django.core.cache import cache + +import pytest +from rest_framework.test import APIClient + +from core.authentication.views import EXCHANGE_CODE_PREFIX, EXCHANGE_CODE_TTL + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def _clear_throttle_cache(): + """Clear cache before each test to reset throttle counters.""" + cache.clear() + + +def test_session_exchange_valid_code(): + """A valid exchange code should return the session ID and be consumed.""" + code = uuid.uuid4().hex + cache_key = f"{EXCHANGE_CODE_PREFIX}{code}" + cache.set(cache_key, "test-session-key-123", EXCHANGE_CODE_TTL) + + client = APIClient() + response = client.post( + "/api/v1.0/auth/session-exchange/", + {"code": code}, + format="json", + ) + + assert response.status_code == 200 + data = response.json() + assert "test-session-key-123" in data.values() + + # Code should be consumed (single-use) + assert cache.get(cache_key) is None + + +def test_session_exchange_invalid_code(): + """An invalid/unknown code should return 400.""" + client = APIClient() + response = client.post( + "/api/v1.0/auth/session-exchange/", + {"code": uuid.uuid4().hex}, + format="json", + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid or expired code." + + +def test_session_exchange_expired_code(): + """An expired code should return 400.""" + code = uuid.uuid4().hex + cache_key = f"{EXCHANGE_CODE_PREFIX}{code}" + # Set with 0 TTL to simulate expiration + cache.set(cache_key, "expired-session", 0) + + client = APIClient() + response = client.post( + "/api/v1.0/auth/session-exchange/", + {"code": code}, + format="json", + ) + + assert response.status_code == 400 + + +def test_session_exchange_code_too_short(): + """A code that's too short should be rejected by validation.""" + client = APIClient() + response = client.post( + "/api/v1.0/auth/session-exchange/", + {"code": "short"}, + format="json", + ) + + assert response.status_code == 400 + + +def test_session_exchange_missing_code(): + """Missing code field should be rejected.""" + client = APIClient() + response = client.post( + "/api/v1.0/auth/session-exchange/", + {}, + format="json", + ) + + assert response.status_code == 400 + + +def test_session_exchange_replay_attack(): + """Using the same code twice should fail the second time.""" + code = uuid.uuid4().hex + cache.set(f"{EXCHANGE_CODE_PREFIX}{code}", "session-123", EXCHANGE_CODE_TTL) + + client = APIClient() + + # First use succeeds + response = client.post( + "/api/v1.0/auth/session-exchange/", + {"code": code}, + format="json", + ) + assert response.status_code == 200 + + # Second use fails + response = client.post( + "/api/v1.0/auth/session-exchange/", + {"code": code}, + format="json", + ) + assert response.status_code == 400 + + +def test_session_exchange_returns_correct_cookie_name(settings): + """The response key should match SESSION_COOKIE_NAME from settings.""" + settings.SESSION_COOKIE_NAME = "meet_sessionid" + code = uuid.uuid4().hex + cache.set(f"{EXCHANGE_CODE_PREFIX}{code}", "my-session", EXCHANGE_CODE_TTL) + + client = APIClient() + response = client.post( + "/api/v1.0/auth/session-exchange/", + {"code": code}, + format="json", + ) + + assert response.status_code == 200 + assert response.json() == {"meet_sessionid": "my-session"} + + +def test_session_exchange_get_not_allowed(): + """GET method should not be allowed on the exchange endpoint.""" + client = APIClient() + response = client.get("/api/v1.0/auth/session-exchange/") + assert response.status_code == 405 diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index caa75fea..02fb676d 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -7,6 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls from rest_framework.routers import DefaultRouter from core.api import get_frontend_configuration, viewsets +from core.authentication.api import session_exchange from core.external_api import viewsets as external_viewsets # - Main endpoints @@ -40,6 +41,9 @@ urlpatterns = [ [ *router.urls, *oidc_urls, + path( + "auth/session-exchange/", session_exchange, name="session_exchange" + ), path("config/", get_frontend_configuration, name="config"), ] ), diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index f6768ea0..f03d09d1 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -344,6 +344,11 @@ class Base(Configuration): environ_name="CREATION_CALLBACK_THROTTLE_RATES", environ_prefix=None, ), + "session_exchange": values.Value( + default="5/minute", + environ_name="SESSION_EXCHANGE_THROTTLE_RATES", + environ_prefix=None, + ), }, } MONITORED_THROTTLE_FAILURE_CALLBACK = ( @@ -459,8 +464,16 @@ class Base(Configuration): ) # OIDC - Authorization Code Flow - OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView" - OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView" + OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView" + OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView" + + # Custom URL schemes allowed for native app OIDC redirects. + # Only these schemes will receive an exchange code in the callback. + NATIVE_APP_REDIRECT_SCHEMES = values.ListValue( + default=["visio"], + environ_name="NATIVE_APP_REDIRECT_SCHEMES", + environ_prefix=None, + ) OIDC_CREATE_USER = values.BooleanValue( default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None ) diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 1f775714..ca48cc41 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "uv_build" [project] name = "meet" -version = "1.11.0" +version = "1.10.0" authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -52,7 +52,7 @@ dependencies = [ "nested-multipart-parser==1.6.0", "psycopg[binary]==3.3.2", "pydantic==2.12.4", - "PyJWT==2.12.0", + "PyJWT==2.11.0", "python-frontmatter==1.1.0", "python-magic==0.4.27", "requests==2.32.5", diff --git a/src/backend/uv.lock b/src/backend/uv.lock index cc9f561c..87ad33b1 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -1157,7 +1157,7 @@ wheels = [ [[package]] name = "meet" -version = "1.11.0" +version = "1.10.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1251,7 +1251,7 @@ requires-dist = [ { name = "nested-multipart-parser", specifier = "==1.6.0" }, { name = "psycopg", extras = ["binary"], specifier = "==3.3.2" }, { name = "pydantic", specifier = "==2.12.4" }, - { name = "pyjwt", specifier = "==2.12.0" }, + { name = "pyjwt", specifier = "==2.11.0" }, { name = "python-frontmatter", specifier = "==1.1.0" }, { name = "python-magic", specifier = "==0.4.27" }, { name = "redis", specifier = "==5.2.1" }, @@ -1754,11 +1754,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.0" +version = "2.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" }, + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, ] [[package]] diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 3d09434d..85cf05d1 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "meet", - "version": "1.11.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meet", - "version": "1.11.0", + "version": "1.10.0", "dependencies": { "@fontsource-variable/material-symbols-outlined": "5.2.34", "@fontsource/material-icons-outlined": "5.2.6", diff --git a/src/frontend/package.json b/src/frontend/package.json index af197f05..74dedb1f 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -1,7 +1,7 @@ { "name": "meet", "private": true, - "version": "1.11.0", + "version": "1.10.0", "type": "module", "scripts": { "dev": "panda codegen && vite", diff --git a/src/frontend/public/assets/logo-suite-numerique.png b/src/frontend/public/assets/logo-suite-numerique.png index b4e95d5b1d0f5794d595b095345c9b3a86e7dabe..840e972e28acc8f035458f05a47ac8d7b0d38385 100644 GIT binary patch literal 11214 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

lxe#sG*51Mvp{8G`Ood9vB~l*1@1nW@1MqmkRF0 z;t=RT^!7)b)6%81?`wjPh8PE8DNJR_pA&8%O1v?{%prtdSMC%dGIX84}w zywAGyY`u-pt6H*ywLg0>c%w|Q*kqhiu(VG!uLa8e{cNF%j|Pj?9aT^8C8M(V{Q)zD zx^p>L8M=YyRaPs;NEgQqwKn&nndbCK-Wth}1za#e8!BJS-;7(y*o|8yt+OO}rKROV@=Gz;c#$XnVa*9JA&>(<^kEo`-cBOwojSR~-Zlsf68hKco3!g{#(p*S2;r9qy zKBoUEdhY)hdbRW^3-2xH-5$Z!*HPJXo|c~2$nc@g+r0+pHNmy)Fr?RsOv)u*OH=g3 zW;zC52tB!_3Fzz4JLBr4>2Y3(6(8Eza+H^J?*~)$#`FWjO zN9pkp<20c-n-!oJM$c22Ypsv5(#Khuw!zdKQkSnWJ8Pc6E>`lN_Reg(aS#Te_$Xs+ zt0UagGyngqgHT*AiE$ExXqfRsAJT=Ii-cAH$O(`VK}WZG*83y#&i_R7)uOJ|!u0xW+@C!;CW=Yx zPxR@5_4M3JX0;bhlwSJ>yPrJ0_O~v*u?zHe-+sR1r+-V?b00C;JF3cm4!8A#Y0~SD z=!qOc8h9nF7S~=-s~^%8p@)YfCQ7Fk$nUwPIh{j!Tvj`6dIO$t0e(KcvFr3Ykzr7# zH;e|T%j8*+5~ zZ;d)}tUSF@nO>#|t(M--Vh=^CR-GR871Q%(Sz>y7FThSQ(}Vx?($TY{y(#5}ImY>{ zMH6#;!PD!irk823ea(s21jB0GkddP9MKffA#3pg-SC<}IO?tUrZD00`&|AyQCi2G+ zaiR(9_$HT#f9E3({(a!Yz3RS)n*K>X$0tH>^kR0jYG5N(SCO3hT$w$=;XyLQ*B+7P zYa67B=>bRn9}tHdF!V|R2=T={s3v@rta1ED(jzWA3GpPA&B!ZhKaR%-8Z*i-m(vg| zOz*1wfK4--U!hTuI@YnMF`<6TqoCNsmR%h^lDYs4X$`VNUKK-fZ3GK@h`VH-Pzp9Q ze}(U{YofQT>0nVXHlsC2qQB@m8iB!_nHw1{=oVR%@ZQ?f+-gng7x*A-OS#nNRg0|- zI?TwAeVS>s_ZGUyz6V=Bo6zN0qvAY@z%7nHrA(=4TCh`qM@8ttz2js79VDMj*>q@g z5d;rs(k~VnE?tf057nHv0n9MVCb2!ZGT3=1!aPTs%w2|FB{Zi5EZ65`!ekjHGO)Q`dG}i!GEGX4{gALDfC1{OdhYH3 zn-f4o93+6F6l#rh=a3{;YoM?1iP3{ad-l|;eLlXV?PAfSmj4xc{Z3cCWDHJ4lc9tR sP?dt$$1+5Rrv7X5SU?DZUjP6A literal 9120 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 KiscICLH`3d1VB~* diff --git a/src/frontend/src/api/fetchApi.ts b/src/frontend/src/api/fetchApi.ts index a3c5039d..3514b9b9 100644 --- a/src/frontend/src/api/fetchApi.ts +++ b/src/frontend/src/api/fetchApi.ts @@ -15,19 +15,7 @@ export const fetchApi = async >( ...options?.headers, }, }) - - let result: T - if (response.status === 204) { - result = undefined as T - } else { - const contentType = response.headers.get('content-type') ?? '' - if (!contentType.includes('application/json')) { - result = undefined as T - } else { - result = (await response.json()) as T - } - } - + const result = await response.json() if (!response.ok) { throw new ApiError(response.status, result) } diff --git a/src/frontend/src/api/queryKeys.ts b/src/frontend/src/api/queryKeys.ts index 13f2805e..ccb21d29 100644 --- a/src/frontend/src/api/queryKeys.ts +++ b/src/frontend/src/api/queryKeys.ts @@ -5,5 +5,4 @@ export const keys = { requestEntry: 'requestEntry', waitingParticipants: 'waitingParticipants', roomCreationCallback: 'roomCreationCallback', - files: 'files', } diff --git a/src/frontend/src/api/useConfig.ts b/src/frontend/src/api/useConfig.ts index e2a255e9..67a67738 100644 --- a/src/frontend/src/api/useConfig.ts +++ b/src/frontend/src/api/useConfig.ts @@ -30,13 +30,6 @@ export interface ApiConfig { expiration_days?: number max_duration?: number } - background_image: { - upload_is_enabled: boolean - max_size: number - max_count_by_user: number - allowed_extensions: string[] - allowed_mimetypes: string[] - } subtitle: { enabled: boolean } diff --git a/src/frontend/src/features/files/api/createFile.ts b/src/frontend/src/features/files/api/createFile.ts deleted file mode 100644 index ffccdac2..00000000 --- a/src/frontend/src/features/files/api/createFile.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { fetchApi } from '@/api/fetchApi' -import { useMutation } from '@tanstack/react-query' -import { ApiFileItem } from '@/features/files/api/types.ts' -import { keys } from '@/api/queryKeys.ts' -import { queryClient } from '@/api/queryClient.ts' - -/** - * Upload a file, using XHR so we can report on progress through a handler. - * - * @param url The URL to PUT the file to. - * @param file The file to upload. - * @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`. - */ -export const uploadFile = ( - url: string, - file: File, - progressHandler: (progress: number) => void -) => - new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - xhr.open('PUT', url) - xhr.setRequestHeader('X-amz-acl', 'private') - xhr.setRequestHeader('Content-Type', file.type) - - xhr.addEventListener('error', reject) - xhr.addEventListener('abort', reject) - - xhr.addEventListener('readystatechange', () => { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - // Make sure to always set the progress to 100% when the upload is done. - // Because 'progress' event listener is not called when the file size is 0. - progressHandler(100) - return resolve(true) - } - reject(new Error(`Failed to perform the upload on ${url}.`)) - } - }) - - xhr.upload.addEventListener('progress', (progressEvent) => { - if (progressEvent.lengthComputable) { - progressHandler( - Math.floor((progressEvent.loaded / progressEvent.total) * 100) - ) - } - }) - - xhr.send(file) - }) - -/** - * Asynchronously creates a new file and uploads it to the server. - * - * @param {object} params - The parameters for the file creation and upload process. - * @param {File} params.file - The file object to be uploaded. - * @param {function} params.onProgress - A callback function that receives the upload progress as a number (0 to 100). - * @returns {Promise} A promise that resolves when the file has been successfully uploaded and the server process is completed. - */ -export const createFile = async ({ - file, - onProgress, -}: { - file: File - onProgress: (progress: number) => void -}): Promise => { - const res = await fetchApi(`/files/`, { - method: 'POST', - body: JSON.stringify({ filename: file.name, type: 'background_image' }), - }) - if (res.upload_state !== 'pending') { - throw new Error('State should be pending right after creation') - } - const policy = res.policy - await uploadFile(policy, file, onProgress) - const createdFile = await fetchApi( - `/files/${res.id}/upload-ended/`, - { - method: 'POST', - } - ) - - // We invalidate the files query to make sure the new file is immediately available. - await queryClient.invalidateQueries({ - queryKey: [keys.files], - }) - return createdFile -} - -export const useCreateFile = () => { - return useMutation({ - mutationFn: createFile, - }) -} diff --git a/src/frontend/src/features/files/api/deleteFile.ts b/src/frontend/src/features/files/api/deleteFile.ts deleted file mode 100644 index 0fdd2bdc..00000000 --- a/src/frontend/src/features/files/api/deleteFile.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { fetchApi } from '@/api/fetchApi' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { keys } from '@/api/queryKeys.ts' - -/** - * Deletes a file specified by its unique identifier. - * - * @param {Object} params - The parameters required for deleting the file. - * @param {string} params.fileId - The unique identifier of the file to be deleted. - * @returns {Promise} A promise that resolves when the file is successfully deleted. - */ -export const deleteFile = async ({ - fileId, -}: { - fileId: string -}): Promise => { - await fetchApi(`/files/${fileId}/`, { - method: 'DELETE', - }) -} - -export const useDeleteFile = () => { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: deleteFile, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: [keys.files], - }) - }, - }) -} diff --git a/src/frontend/src/features/files/api/listFiles.ts b/src/frontend/src/features/files/api/listFiles.ts deleted file mode 100644 index db57a4a1..00000000 --- a/src/frontend/src/features/files/api/listFiles.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { fetchApi } from '@/api/fetchApi' -import { keepPreviousData, useQuery } from '@tanstack/react-query' -import { keys } from '@/api/queryKeys' -import { - ApiFileItem, - ApiFileType, - ApiFileUploadState, -} from '@/features/files/api/types.ts' -import { useUser } from '@/features/auth' -import { useConfig } from '@/api/useConfig.ts' - -type ListFilesResponse = { - count: number - next: string | null - previous: string | null - results: ApiFileItem[] -} - -type ListFilesFilters = { - is_creator_me?: boolean - type?: ApiFileType - upload_state?: ApiFileUploadState - is_deleted?: boolean -} - -export type ListFilesParams = { - filters?: ListFilesFilters - pagination: { - page: number - pageSize: number - } -} - -export const listMyFiles = async ({ - filters = {}, - pagination: { page, pageSize }, -}: ListFilesParams): Promise => { - const query = new URLSearchParams() - query.append('page', page.toString()) - query.append('page_size', pageSize.toString()) - if (filters?.is_creator_me ?? true) { - query.append('is_creator_me', 'true') - } - if (filters?.type) { - query.append('type', filters.type) - } - if (filters?.upload_state) { - query.append('upload_state', filters.upload_state) - } - if (typeof filters?.is_deleted === 'boolean') { - query.append('is_deleted', filters.is_deleted ? 'true' : 'false') - } - - return fetchApi(`/files?${query.toString()}`, { - method: 'GET', - }) -} - -export const useListMyFiles = (params: Parameters[0]) => { - const { isLoggedIn } = useUser() - const { data: appConfig } = useConfig() - return useQuery({ - queryKey: [keys.files, params], - queryFn: () => listMyFiles(params), - refetchOnMount: 'always', - placeholderData: keepPreviousData, - enabled: - isLoggedIn && appConfig?.background_image?.upload_is_enabled === true, - }) -} diff --git a/src/frontend/src/features/files/api/types.ts b/src/frontend/src/features/files/api/types.ts deleted file mode 100644 index 5c7891d6..00000000 --- a/src/frontend/src/features/files/api/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -export type ApiFileCreator = { - id: string // UUID - full_name: string | null - short_name: string | null -} - -export type ApiFileType = 'background_image' -export type ApiFileUploadState = 'pending' | 'ready' - -export type ApiFileItem = { - id: string // UUID - created_at: string // ISO datetime string - updated_at: string // ISO datetime string - title: string - type: ApiFileType - creator: ApiFileCreator - deleted_at: string | null - hard_deleted_at: string | null - filename: string - upload_state: ApiFileUploadState - mimetype: string // e.g. "image/png" - size: number // file size in bytes - description: string | null -} & ( - | { - upload_state: 'ready' - url: string - } - | { - upload_state: 'pending' - policy: string - url: null - } -) diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index d7c5b5f1..496cce6d 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -215,8 +215,8 @@ export const Conference = ({ audio={userConfig.audioEnabled} video={ userConfig.videoEnabled && { - processor: BackgroundProcessorFactory.fromProcessorConfig( - userConfig.processorConfig + processor: BackgroundProcessorFactory.deserializeProcessor( + userConfig.processorSerialized ), } } diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index 77b99785..09668b09 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -4,15 +4,15 @@ import { css } from '@/styled-system/css' import { Screen } from '@/layout/Screen' import { useEffect, useMemo, useRef, useState } from 'react' import { - createLocalAudioTrack, createLocalVideoTrack, + createLocalAudioTrack, LocalAudioTrack, LocalVideoTrack, Track, } from 'livekit-client' import { H } from '@/primitives/H' import { Field } from '@/primitives/Field' -import { Button, Dialog, Form, Text } from '@/primitives' +import { Button, Dialog, Text, Form } from '@/primitives' import { VStack } from '@/styled-system/jsx' import { Heading } from 'react-aria-components' import { RiImageCircleAiFill } from '@remixicon/react' @@ -44,7 +44,8 @@ const onError = (e: Error) => console.error('ERROR', e) const Effects = ({ videoTrack, -}: Pick) => { + onSubmit, +}: Pick) => { const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' }) const [isDialogOpen, setIsDialogOpen] = useState(false) const openDialog = () => setIsDialogOpen(true) @@ -80,7 +81,7 @@ const Effects = ({ > {t('subTitle')} - + - - ) - )} - {!canUploadBackground && - uploadNotPossibleSnap.imageBackgroundConfig && ( - + {[...Array(8).keys()].map((i) => { + const imagePath = `/assets/backgrounds/${i + 1}.jpg` + const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg` + const tooltipText = tooltipVirtualBackground(i) + return ( + { - toggleEffect( - uploadNotPossibleSnap.imageBackgroundConfig! - ) - }} - isSelected={ - deriveIdFromProcessorConfig( - uploadNotPossibleSnap.imageBackgroundConfig - ) === selectedId + aria-label={ariaLabelVirtualBackground(i, imagePath)} + isDisabled={processorPendingReveal || isDisabled} + onChange={async () => + await toggleEffect(ProcessorType.VIRTUAL, { + imagePath, + }) } + isSelected={isSelected(ProcessorType.VIRTUAL, { + imagePath, + })} className={css({ bgSize: 'cover', })} style={{ - backgroundImage: `url(${uploadNotPossibleSnap.imageBackgroundConfig.imagePath})`, + backgroundImage: `url(${thumbnailPath})`, }} - data-attr={`toggle-virtual-local`} + data-attr={`toggle-virtual-${i}`} /> - )} - { - if (e && e.item(0)) { - const file = e.item(0) as File - handleNewBackgroundFilePicked(file) - } - }} - > - - - - {!isLoggedIn && ( - - {t('virtual.personal.notLoggedInWarning')} - - )} - {!canUploadBackground && isLoggedIn && ( - - {t('virtual.personal.warningUploadDisabled')} - - )} - {hasReachedMaxNbBackgrounds && ( - - {t('virtual.personal.uploadLimitReached')} - - )} - -

- - {t('virtual.presets.title')} - -
- {processorOptions.virtualBackgrounds.map((option) => ( - - toggleEffect(option.config)} - isSelected={option.isSelected} - className={css({ - bgSize: 'cover', - })} - style={{ - backgroundImage: `url(${option.thumbnailPath})`, - }} - data-attr={`toggle-virtual-preset-${option.index}`} - /> - - ))}
@@ -911,32 +483,6 @@ export const EffectsConfiguration = ({ )} - setPersonalBackgroundHasError(false)} - onOpenChange={() => setPersonalBackgroundHasError(false)} - > -

- {t( - `virtual.personal.errors.${personalBackgroundError}.description`, - filePickerErrorContext - )} -

- - - -
) } diff --git a/src/frontend/src/features/rooms/livekit/components/effects/FunnyEffects.tsx b/src/frontend/src/features/rooms/livekit/components/effects/FunnyEffects.tsx index c2d7bba6..c19eb43a 100644 --- a/src/frontend/src/features/rooms/livekit/components/effects/FunnyEffects.tsx +++ b/src/frontend/src/features/rooms/livekit/components/effects/FunnyEffects.tsx @@ -27,7 +27,7 @@ export const FunnyEffects = ({ showFrench: false, } } - return { ...processor.options } + return processor.serialize().options } const options = getOptions() diff --git a/src/frontend/src/features/rooms/livekit/hooks/usePersistentUserChoices.ts b/src/frontend/src/features/rooms/livekit/hooks/usePersistentUserChoices.ts index 8edc2b82..445b38ee 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/usePersistentUserChoices.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/usePersistentUserChoices.ts @@ -1,7 +1,7 @@ import { useSnapshot } from 'valtio' import { userChoicesStore } from '@/stores/userChoices' import type { VideoResolution } from '@/stores/userChoices' -import { ProcessorConfig } from '@/features/rooms/livekit/components/blur' +import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur' import type { VideoQuality } from 'livekit-client' export function usePersistentUserChoices() { @@ -36,8 +36,10 @@ export function usePersistentUserChoices() { saveNoiseReductionEnabled: (enabled: boolean) => { userChoicesStore.noiseReductionEnabled = enabled }, - saveProcessorConfig: (processorConfig: ProcessorConfig | undefined) => { - userChoicesStore.processorConfig = processorConfig + saveProcessorSerialized: ( + processorSerialized: ProcessorSerialized | undefined + ) => { + userChoicesStore.processorSerialized = processorSerialized }, } } diff --git a/src/frontend/src/features/settings/components/tabs/VideoTab.tsx b/src/frontend/src/features/settings/components/tabs/VideoTab.tsx index 101da4a8..7a9dbedd 100644 --- a/src/frontend/src/features/settings/components/tabs/VideoTab.tsx +++ b/src/frontend/src/features/settings/components/tabs/VideoTab.tsx @@ -31,7 +31,7 @@ export const VideoTab = ({ id }: VideoTabProps) => { const { userChoices: { videoDeviceId, - processorConfig, + processorSerialized, videoPublishResolution, videoSubscribeQuality, }, @@ -78,7 +78,7 @@ export const VideoTab = ({ id }: VideoTabProps) => { resolution: VideoPresets[key].resolution, deviceId: { exact: videoDeviceId }, processor: - BackgroundProcessorFactory.fromProcessorConfig(processorConfig), + BackgroundProcessorFactory.deserializeProcessor(processorSerialized), }) } } diff --git a/src/frontend/src/features/subtitle/component/CaptionsSettings.tsx b/src/frontend/src/features/subtitle/component/CaptionsSettings.tsx index cf35d571..9f7ddefa 100644 --- a/src/frontend/src/features/subtitle/component/CaptionsSettings.tsx +++ b/src/frontend/src/features/subtitle/component/CaptionsSettings.tsx @@ -6,11 +6,8 @@ import { useSnapshot } from 'valtio' import { accessibilityStore, type CaptionTextSize, - type CaptionColor, CAPTION_TEXT_SIZE_OPTIONS, - CAPTION_COLOR_OPTIONS, } from '@/stores/accessibility' -import { useAreSubtitlesAvailable } from '../hooks/useAreSubtitlesAvailable' export const CaptionsSettings = () => { const { t } = useTranslation('settings', { @@ -27,27 +24,6 @@ export const CaptionsSettings = () => { [t] ) - const captionFontColorItems = useMemo( - () => - CAPTION_COLOR_OPTIONS.map((color) => ({ - value: color, - label: t(`fontColor.options.${color}`), - })), - [t] - ) - - const captionBackgroundColorItems = useMemo( - () => - CAPTION_COLOR_OPTIONS.map((color) => ({ - value: color, - label: t(`backgroundColor.options.${color}`), - })), - [t] - ) - - const areSubtitlesAvailable = useAreSubtitlesAvailable() - if (!areSubtitlesAvailable) return null - return (
  • { > {t('heading')} -
    - { - accessibilityStore.captionTextSize = key as CaptionTextSize - }} - wrapperProps={{ noMargin: true, fullWidth: true }} - /> - { - accessibilityStore.captionFontColor = key as CaptionColor - }} - wrapperProps={{ noMargin: true, fullWidth: true }} - /> - { - accessibilityStore.captionBackgroundColor = key as CaptionColor - }} - wrapperProps={{ noMargin: true, fullWidth: true }} - /> -
    + { + accessibilityStore.captionTextSize = key as CaptionTextSize + }} + wrapperProps={{ noMargin: true, fullWidth: true }} + />
  • ) } diff --git a/src/frontend/src/features/subtitle/component/Subtitles.tsx b/src/frontend/src/features/subtitle/component/Subtitles.tsx index f434d98d..bbc76a60 100644 --- a/src/frontend/src/features/subtitle/component/Subtitles.tsx +++ b/src/frontend/src/features/subtitle/component/Subtitles.tsx @@ -12,8 +12,6 @@ import { useSnapshot } from 'valtio' import { accessibilityStore, CAPTION_TEXT_SIZE_OPTIONS, - CAPTION_FONT_COLOR_VALUES, - CAPTION_BACKGROUND_COLOR_VALUES, type CaptionTextSize, } from '@/stores/accessibility' @@ -94,14 +92,10 @@ const useTranscriptionState = () => { } const Transcription = ({ row }: { row: TranscriptionRow }) => { - const { captionTextSize, captionFontColor, captionBackgroundColor } = - useSnapshot(accessibilityStore) + const { captionTextSize } = useSnapshot(accessibilityStore) const participantColor = getParticipantColor(row.participant) const participantName = getParticipantName(row.participant) const { fontSize, lineHeight } = CAPTION_FONT_SIZES[captionTextSize] - const fontColor = CAPTION_FONT_COLOR_VALUES[captionFontColor] - const backgroundColor = - CAPTION_BACKGROUND_COLOR_VALUES[captionBackgroundColor] const getDisplayText = (row: TranscriptionRow): string => { return row.segments @@ -134,9 +128,9 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => { />
    {participantName} @@ -144,10 +138,8 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {

    {displayText}

    diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index 9aff22bf..df9821d2 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -273,40 +273,17 @@ "title": "Virtueller Hintergrund", "selectedLabel": "Hintergrund angewendet:", "apply": "Ersetze deinen Hintergrund:", - "personal": { - "title": "Meine Hintergründe", - "selectFileTooltip": "Wähle ein Bild aus, das als persönlicher Hintergrund verwendet werden soll", - "notLoggedInWarning": "Du bist nicht angemeldet, der persönliche Hintergrund wird nicht von einem Meeting zum anderen gespeichert.", - "warningUploadDisabled": "Persönliche Hintergründe werden derzeit nicht von einem Meeting zum anderen gespeichert.", - "uploadLimitReached": "Du kannst keine weiteren persönlichen Hintergründe hinzufügen.", - "uploadInProgress": "Datei wird hochgeladen…", - "errors": { - "close": "Schließen", - "file_too_large": { - "title": "Datei zu groß", - "description": "Die Datei ist zu groß. Bitte wähle eine Datei mit weniger als {{maxSize, number}} MB." - }, - "invalid_file_type": { - "title": "Ungültiger Dateityp", - "description": "Der Dateityp wird nicht unterstützt. Bitte wähle eine {{allowedExtension}}-Datei." - } - } - }, - "presets": { - "title": "Vorschläge", - "descriptions": { - "0": "Gerilltes Holzmöbel", - "1": "Besprechungsraum", - "2": "Loft mit schwarzer Glaswand", - "3": "Esszimmer", - "4": "Holzregale", - "5": "Holztreppe", - "6": "Graue Bibliothek", - "7": "Kaffeetheke" - } + "descriptions": { + "0": "Gerilltes Holzmöbel", + "1": "Besprechungsraum", + "2": "Loft mit schwarzer Glaswand", + "3": "Esszimmer", + "4": "Holzregale", + "5": "Holztreppe", + "6": "Graue Bibliothek", + "7": "Kaffeetheke" } }, - "faceLandmarks": { "title": "Visuelle Effekte", "glasses": { @@ -320,7 +297,7 @@ } }, "sidePanel": { - "ariaLabel": "Seitenleiste - {{title}}", + "ariaLabel": "Seitenleiste", "backToTools": "Zurück zu den Meeting-Tools", "heading": { "participants": "Teilnehmende", @@ -355,11 +332,11 @@ "tools": { "transcript": { "title": "Transkribieren", - "body": "Wandelt Meetings in Text um." + "body": "Das Gespräch aufzeichnen." }, "screenRecording": { - "title": "Aufnehmen", - "body": "Speichert Meetings als Video." + "title": "Aufzeichnen", + "body": "Das Meeting aufzeichnen." } } }, diff --git a/src/frontend/src/locales/de/settings.json b/src/frontend/src/locales/de/settings.json index 9879d21b..bde3f7f3 100644 --- a/src/frontend/src/locales/de/settings.json +++ b/src/frontend/src/locales/de/settings.json @@ -126,34 +126,6 @@ "medium": "Mittel", "large": "Groß" } - }, - "fontColor": { - "label": "Schriftfarbe", - "options": { - "default": "Standard", - "white": "Weiß", - "black": "Schwarz", - "blue": "Blau", - "green": "Grün", - "red": "Rot", - "yellow": "Gelb", - "cyan": "Cyan", - "magenta": "Magenta" - } - }, - "backgroundColor": { - "label": "Hintergrundfarbe", - "options": { - "default": "Standard", - "white": "Weiß", - "black": "Schwarz", - "blue": "Blau", - "green": "Grün", - "red": "Rot", - "yellow": "Gelb", - "cyan": "Cyan", - "magenta": "Magenta" - } } } }, diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 85c3efef..034246b6 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -270,40 +270,18 @@ } }, "virtual": { - "title": "Virtual backgrounds", + "title": "Virtual background", "selectedLabel": "Background applied:", "apply": "Replace your background:", - "personal": { - "title": "My backgrounds", - "selectFileTooltip": "Select an image file to use as a personal background", - "notLoggedInWarning": "You are not logged-in, personal backgrounds won't be saved from one meeting to the other.", - "warningUploadDisabled": "Personal backgrounds are currently not saved from one meeting to the other.", - "uploadLimitReached": "You cannot upload more personal backgrounds.", - "uploadInProgress": "Image is being uploaded…", - "errors": { - "close": "Close", - "file_too_large": { - "title": "File too large", - "description": "The file is too large. Please choose a file smaller than {{maxSize, number}} MB." - }, - "invalid_file_type": { - "title": "Invalid file type", - "description": "The file type is not supported. Please choose a {{allowedExtension, list(type: 'disjunction')}} file." - } - } - }, - "presets": { - "title": "Suggestions", - "descriptions": { - "0": "Fluted wooden furniture", - "1": "Meeting room", - "2": "Loft with black glass partition", - "3": "Dining room", - "4": "Wooden shelves", - "5": "Wooden staircase", - "6": "Gray library", - "7": "Coffee counter" - } + "descriptions": { + "0": "Fluted wooden furniture", + "1": "Meeting room", + "2": "Loft with black glass partition", + "3": "Dining room", + "4": "Wooden shelves", + "5": "Wooden staircase", + "6": "Gray library", + "7": "Coffee counter" } }, "faceLandmarks": { @@ -319,7 +297,7 @@ } }, "sidePanel": { - "ariaLabel": "Sidepanel - {{title}}", + "ariaLabel": "Sidepanel", "backToTools": "Back to meeting tools", "heading": { "participants": "Participants", @@ -354,11 +332,11 @@ "tools": { "transcript": { "title": "Transcribe", - "body": "Turn meetings into text." + "body": "Record the conversation." }, "screenRecording": { "title": "Record", - "body": "Save meetings as video." + "body": "Record the meeting." } } }, diff --git a/src/frontend/src/locales/en/settings.json b/src/frontend/src/locales/en/settings.json index 722978b0..ecae6528 100644 --- a/src/frontend/src/locales/en/settings.json +++ b/src/frontend/src/locales/en/settings.json @@ -126,34 +126,6 @@ "medium": "Medium", "large": "Large" } - }, - "fontColor": { - "label": "Font color", - "options": { - "default": "Default", - "white": "White", - "black": "Black", - "blue": "Blue", - "green": "Green", - "red": "Red", - "yellow": "Yellow", - "cyan": "Cyan", - "magenta": "Magenta" - } - }, - "backgroundColor": { - "label": "Background color", - "options": { - "default": "Default", - "white": "White", - "black": "Black", - "blue": "Blue", - "green": "Green", - "red": "Red", - "yellow": "Yellow", - "cyan": "Cyan", - "magenta": "Magenta" - } } } }, diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 02f0a21f..f06afb32 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -270,40 +270,18 @@ } }, "virtual": { - "title": "Arrière-plans virtuels", + "title": "Arrière-plan virtuel", "selectedLabel": "Arrière-plan appliqué :", "apply": "Remplacer votre arrière plan :", - "personal": { - "title": "Mes arrière-plans", - "selectFileTooltip": "Sélectionnez une image à utiliser comme arrière-plan", - "notLoggedInWarning": "Vous n'êtes pas connecté, l'arrière-plan personnel ne sera pas sauvegardé d'une réunion à l'autre.", - "warningUploadDisabled": "Les arrière-plans personnels ne sont actuellement pas sauvegardés d'une réunion à l'autre.", - "uploadLimitReached": "Vous ne pouvez pas ajouter plus d'arrière-plans personnels.", - "uploadInProgress": "Image en cours d'envoi…", - "errors": { - "close": "Fermer", - "file_too_large": { - "title": "Fichier trop volumineux", - "description": "Le fichier est trop volumineux. Veuillez choisir un fichier de moins de {{maxSize, number}} Mo." - }, - "invalid_file_type": { - "title": "Type de fichier non valide", - "description": "Le type de fichier n'est pas pris en charge. Veuillez choisir un fichier {{allowedExtension}}." - } - } - }, - "presets": { - "title": "Suggestions", - "descriptions": { - "0": "Meuble cannelé en bois", - "1": "Salle de réunion", - "2": "Loft avec verrière noire", - "3": "Salle à manger", - "4": "Étagères en bois", - "5": "Escalier en bois", - "6": "Bibliothèque grise", - "7": "Comptoir de café" - } + "descriptions": { + "0": "Meuble cannelé en bois", + "1": "Salle de réunion", + "2": "Loft avec verrière noire", + "3": "Salle à manger", + "4": "Étagères en bois", + "5": "Escalier en bois", + "6": "Bibliothèque grise", + "7": "Comptoir de café" } }, "faceLandmarks": { @@ -319,7 +297,7 @@ } }, "sidePanel": { - "ariaLabel": "Panneau latéral - {{title}}", + "ariaLabel": "Panneau latéral", "backToTools": "Retour aux outils de réunion", "heading": { "participants": "Participants", @@ -354,11 +332,11 @@ "tools": { "transcript": { "title": "Transcrire", - "body": "Transcrire la réunion." + "body": "Enregistrer la conversation." }, "screenRecording": { "title": "Enregistrer", - "body": "Enregistrer la réunion en vidéo." + "body": "Enregistrer la réunion." } } }, diff --git a/src/frontend/src/locales/fr/settings.json b/src/frontend/src/locales/fr/settings.json index 99e29c82..4da44c33 100644 --- a/src/frontend/src/locales/fr/settings.json +++ b/src/frontend/src/locales/fr/settings.json @@ -126,34 +126,6 @@ "medium": "Moyen", "large": "Grand" } - }, - "fontColor": { - "label": "Couleur du texte", - "options": { - "default": "Par défaut", - "white": "Blanc", - "black": "Noir", - "blue": "Bleu", - "green": "Vert", - "red": "Rouge", - "yellow": "Jaune", - "cyan": "Cyan", - "magenta": "Magenta" - } - }, - "backgroundColor": { - "label": "Couleur de fond", - "options": { - "default": "Par défaut", - "white": "Blanc", - "black": "Noir", - "blue": "Bleu", - "green": "Vert", - "red": "Rouge", - "yellow": "Jaune", - "cyan": "Cyan", - "magenta": "Magenta" - } } } }, diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index b6364333..4979c86c 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -273,37 +273,15 @@ "title": "Virtuele achtergrond", "selectedLabel": "Achtergrond toegepast:", "apply": "Vervang je achtergrond:", - "personal": { - "title": "Mijn achtergronden", - "selectFileTooltip": "Selecteer een afbeeldingsbestand om te gebruiken als persoonlijke achtergrond", - "notLoggedInWarning": "U bent niet ingelogd, persoonlijke achtergronden worden niet opgeslagen van de ene vergadering naar de andere.", - "warningUploadDisabled": "Persoonlijke achtergronden worden momenteel niet opgeslagen van de ene vergadering naar de andere.", - "uploadLimitReached": "U kunt geen persoonlijke achtergronden meer uploaden.", - "uploadInProgress": "Afbeelding wordt geüpload…", - "errors": { - "close": "Sluiten", - "file_too_large": { - "title": "Bestand te groot", - "description": "Het bestand is te groot. Kies een bestand kleiner dan {{maxSize, number}} MB." - }, - "invalid_file_type": { - "title": "Ongeldig bestandstype", - "description": "Het bestandstype wordt niet ondersteund. Kies een {{allowedExtension, list(type: 'disjunction')}} bestand." - } - } - }, - "presets": { - "title": "Suggesties", - "descriptions": { - "0": "Geprofileerd houten meubel", - "1": "Vergaderruimte", - "2": "Loft met zwarte glaswand", - "3": "Eetkamer", - "4": "Houten planken", - "5": "Houten trap", - "6": "Grijze bibliotheek", - "7": "Koffiebar" - } + "descriptions": { + "0": "Geprofileerd houten meubel", + "1": "Vergaderruimte", + "2": "Loft met zwarte glaswand", + "3": "Eetkamer", + "4": "Houten planken", + "5": "Houten trap", + "6": "Grijze bibliotheek", + "7": "Koffiebar" } }, "faceLandmarks": { @@ -319,7 +297,7 @@ } }, "sidePanel": { - "ariaLabel": "Zijbalk - {{title}}", + "ariaLabel": "Zijbalk", "backToTools": "Terug naar vergadertools", "heading": { "participants": "Deelnemers", @@ -354,11 +332,11 @@ "tools": { "transcript": { "title": "Transcriberen", - "body": "Zet vergaderingen om in tekst." + "body": "Het gesprek opnemen." }, "screenRecording": { "title": "Opnemen", - "body": "Sla vergaderingen op als video." + "body": "De vergadering opnemen." } } }, diff --git a/src/frontend/src/locales/nl/settings.json b/src/frontend/src/locales/nl/settings.json index ec971879..587f9597 100644 --- a/src/frontend/src/locales/nl/settings.json +++ b/src/frontend/src/locales/nl/settings.json @@ -126,34 +126,6 @@ "medium": "Gemiddeld", "large": "Groot" } - }, - "fontColor": { - "label": "Tekstkleur", - "options": { - "default": "Standaard", - "white": "Wit", - "black": "Zwart", - "blue": "Blauw", - "green": "Groen", - "red": "Rood", - "yellow": "Geel", - "cyan": "Cyaan", - "magenta": "Magenta" - } - }, - "backgroundColor": { - "label": "Achtergrondkleur", - "options": { - "default": "Standaard", - "white": "Wit", - "black": "Zwart", - "blue": "Blauw", - "green": "Groen", - "red": "Rood", - "yellow": "Geel", - "cyan": "Cyaan", - "magenta": "Magenta" - } } } }, diff --git a/src/frontend/src/primitives/Spinner.tsx b/src/frontend/src/primitives/Spinner.tsx index 3224f442..d921d11a 100644 --- a/src/frontend/src/primitives/Spinner.tsx +++ b/src/frontend/src/primitives/Spinner.tsx @@ -20,11 +20,7 @@ export const Spinner = ({ const r = 14 - strokeWidth const c = 2 * r * Math.PI return ( - + {({ percentage }) => (
    = { - default: '#FFFFFF', - white: '#FFFFFF', - black: '#000000', - blue: '#0000FF', - green: '#00FF00', - red: '#FF0000', - yellow: '#FFFF00', - cyan: '#00FFFF', - magenta: '#FF00FF', -} - -export const CAPTION_BACKGROUND_COLOR_VALUES: Record = { - default: 'rgba(0, 0, 0, 0.75)', - black: 'rgba(0, 0, 0, 0.75)', - white: 'rgba(255, 255, 255, 0.75)', - blue: 'rgba(0, 0, 255, 0.75)', - green: 'rgba(0, 255, 0, 0.75)', - red: 'rgba(255, 0, 0, 0.75)', - yellow: 'rgba(255, 255, 0, 0.75)', - cyan: 'rgba(0, 255, 255, 0.75)', - magenta: 'rgba(255, 0, 255, 0.75)', -} - type AccessibilityState = { announceReactions: boolean captionTextSize: CaptionTextSize - captionFontColor: CaptionColor - captionBackgroundColor: CaptionColor } const DEFAULT_STATE: AccessibilityState = { announceReactions: false, captionTextSize: 'medium', - captionFontColor: 'default', - captionBackgroundColor: 'default', } function getAccessibilityState(): AccessibilityState { @@ -76,21 +25,10 @@ function getAccessibilityState(): AccessibilityState { const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY) if (stored) { const parsed = JSON.parse(stored) - const captionTextSize = CAPTION_TEXT_SIZE_OPTIONS.includes( - parsed.captionTextSize - ) + const validCaptionSizes = CAPTION_TEXT_SIZE_OPTIONS + const captionTextSize = validCaptionSizes.includes(parsed.captionTextSize) ? parsed.captionTextSize : DEFAULT_STATE.captionTextSize - const captionFontColor = CAPTION_COLOR_OPTIONS.includes( - parsed.captionFontColor - ) - ? parsed.captionFontColor - : DEFAULT_STATE.captionFontColor - const captionBackgroundColor = CAPTION_COLOR_OPTIONS.includes( - parsed.captionBackgroundColor - ) - ? parsed.captionBackgroundColor - : DEFAULT_STATE.captionBackgroundColor return { ...DEFAULT_STATE, ...parsed, @@ -99,8 +37,6 @@ function getAccessibilityState(): AccessibilityState { ? parsed.announceReactions : DEFAULT_STATE.announceReactions, captionTextSize, - captionFontColor, - captionBackgroundColor, } } diff --git a/src/frontend/src/stores/userChoices.ts b/src/frontend/src/stores/userChoices.ts index 0d3e89d5..345838cb 100644 --- a/src/frontend/src/stores/userChoices.ts +++ b/src/frontend/src/stores/userChoices.ts @@ -1,19 +1,16 @@ import { proxy, subscribe } from 'valtio' -import { - ProcessorConfig, - ProcessorType, -} from '@/features/rooms/livekit/components/blur' +import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur' import { loadUserChoices, - LocalUserChoices as LocalUserChoicesLK, saveUserChoices, + LocalUserChoices as LocalUserChoicesLK, } from '@livekit/components-core' import { VideoQuality } from 'livekit-client' export type VideoResolution = 'h720' | 'h360' | 'h180' export type LocalUserChoices = LocalUserChoicesLK & { - processorConfig?: ProcessorConfig + processorSerialized?: ProcessorSerialized noiseReductionEnabled?: boolean audioOutputDeviceId?: string videoPublishResolution?: VideoResolution @@ -31,33 +28,7 @@ function getUserChoicesState(): LocalUserChoices { } export const userChoicesStore = proxy(getUserChoicesState()) + subscribe(userChoicesStore, () => { saveUserChoices(userChoicesStore, false) }) - -// we run some logic on store loading to check if the processor config is still valid -if (userChoicesStore.processorConfig?.type === ProcessorType.VIRTUAL) { - if (userChoicesStore.processorConfig.imagePath.startsWith('blob:')) { - // this happens when a not authenticated user had changed their background image - // we restore clear the processor config to avoid displaying a black screen. - userChoicesStore.processorConfig = undefined - } else if (userChoicesStore.processorConfig.fileId) { - // Checking if the image is still available / accessible - await fetch(userChoicesStore.processorConfig.imagePath, { - // We bypass the cache to ensure we have access - cache: 'reload', - }) - .then((response) => { - // if we cannot fetch the image (likely a 401 from the backend because - // the user is not logged in anymore, etc.), - // we clear the processor config to avoid displaying a black screen. - // This can happen when the user logs out for instance, etc. - if (!response.ok) { - userChoicesStore.processorConfig = undefined - } - }) - .catch(() => { - userChoicesStore.processorConfig = undefined - }) - } -} diff --git a/src/frontend/src/styles/index.css b/src/frontend/src/styles/index.css index 39504da3..900d7a3a 100644 --- a/src/frontend/src/styles/index.css +++ b/src/frontend/src/styles/index.css @@ -95,14 +95,3 @@ html:has(.lk-video-conference) { U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } - -.hoverGroup .hoverGroupChild { - opacity: 0; - pointer-events: none; -} - -.hoverGroup:hover .hoverGroupChild, -.hoverGroup:focus-within .hoverGroupChild { - opacity: 1; - pointer-events: auto; -} diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index 0887b027..fd6b5d5c 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -14,14 +14,6 @@ export default defineConfig(({ mode }) => { port: parseInt(env.VITE_PORT) || 3000, host: env.VITE_HOST ?? 'localhost', allowedHosts: ['.nip.io'], - // In a local dev setup, we proxy the media server ourselves to avoid CORS issues - proxy: { - '/media': { - target: 'http://localhost:8083', - changeOrigin: true, - secure: false - } - } }, } }) diff --git a/src/mail/mjml/screen_recording.mjml b/src/mail/mjml/screen_recording.mjml index 24d23eb5..75c69933 100644 --- a/src/mail/mjml/screen_recording.mjml +++ b/src/mail/mjml/screen_recording.mjml @@ -9,7 +9,7 @@ align="center" src="{{logo_img}}" width="320px" - alt="{%trans 'Logo email' %} {{brandname}}" + alt="{%trans 'Logo email' %}" /> diff --git a/src/mail/package-lock.json b/src/mail/package-lock.json index bd1ffa19..09afbbd9 100644 --- a/src/mail/package-lock.json +++ b/src/mail/package-lock.json @@ -1,12 +1,12 @@ { "name": "mail_mjml", - "version": "1.11.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mail_mjml", - "version": "1.11.0", + "version": "1.10.0", "license": "MIT", "dependencies": { "@html-to/text-cli": "0.5.4", diff --git a/src/mail/package.json b/src/mail/package.json index 5fa64419..be6cb5ef 100644 --- a/src/mail/package.json +++ b/src/mail/package.json @@ -1,6 +1,6 @@ { "name": "mail_mjml", - "version": "1.11.0", + "version": "1.10.0", "description": "An util to generate html and text django's templates from mjml templates", "type": "module", "dependencies": { diff --git a/src/sdk/package-lock.json b/src/sdk/package-lock.json index ff2db31b..26f82f73 100644 --- a/src/sdk/package-lock.json +++ b/src/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "sdk", - "version": "1.11.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sdk", - "version": "1.11.0", + "version": "1.10.0", "license": "ISC", "workspaces": [ "./library", diff --git a/src/sdk/package.json b/src/sdk/package.json index 8806f18f..e6a42814 100644 --- a/src/sdk/package.json +++ b/src/sdk/package.json @@ -1,6 +1,6 @@ { "name": "sdk", - "version": "1.11.0", + "version": "1.10.0", "author": "", "license": "ISC", "description": "", diff --git a/src/summary/pyproject.toml b/src/summary/pyproject.toml index 39a3ce1d..bc300add 100644 --- a/src/summary/pyproject.toml +++ b/src/summary/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "summary" -version = "1.11.0" +version = "1.10.0" dependencies = [ "fastapi[standard]>=0.105.0", "uvicorn>=0.24.0", @@ -21,16 +21,8 @@ dependencies = [ [project.optional-dependencies] dev = [ "ruff==0.14.4", - "pytest==9.0.2", - "responses>=0.25.8", ] -[tool.pytest.ini_options] -markers = [ - "api: Test the API", -] -testpaths = ["tests"] - [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" @@ -57,7 +49,6 @@ select = [ "T20", # flake8-print "W", # pycodestyle warning ] -ignore= ["PLR2004"] [tool.ruff.lint.per-file-ignores] "tests/*" = [ diff --git a/src/summary/summary/core/locales/de.py b/src/summary/summary/core/locales/de.py index 9d13f413..ae792b6a 100644 --- a/src/summary/summary/core/locales/de.py +++ b/src/summary/summary/core/locales/de.py @@ -23,7 +23,8 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen: """, download_header_template=( - "\n*[Laden Sie hier Ihre Aufnahme herunter (externer Link)]({download_link})*\n" + "\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", diff --git a/src/summary/summary/core/locales/en.py b/src/summary/summary/core/locales/en.py index a70ef1a9..6534e924 100644 --- a/src/summary/summary/core/locales/en.py +++ b/src/summary/summary/core/locales/en.py @@ -23,7 +23,7 @@ A few things we recommend you check: """, download_header_template=( - "\n*[Download your recording (external link)]({download_link})*\n" + "\n*Download your recording by [following this link]({download_link})*\n" ), hallucination_replacement_text="[Unable to transcribe text]", document_default_title="Transcription", diff --git a/src/summary/summary/core/locales/fr.py b/src/summary/summary/core/locales/fr.py index 584c2036..48a1f00e 100644 --- a/src/summary/summary/core/locales/fr.py +++ b/src/summary/summary/core/locales/fr.py @@ -23,7 +23,7 @@ Quelques points que nous vous conseillons de vérifier : """, download_header_template=( - "\n*[Télécharger votre enregistrement (lien externe)]({download_link})*\n" + "\n*Télécharger votre enregistrement en [suivant ce lien]({download_link})*\n" ), hallucination_replacement_text="[Texte impossible à transcrire]", document_default_title="Transcription", diff --git a/src/summary/summary/core/locales/nl.py b/src/summary/summary/core/locales/nl.py index d57e6cd5..0cb00213 100644 --- a/src/summary/summary/core/locales/nl.py +++ b/src/summary/summary/core/locales/nl.py @@ -23,7 +23,7 @@ Een paar punten die wij u aanraden te controleren: """, download_header_template=( - "\n*[Download hier je opname (externe link)]({download_link})*\n" + "\n*Download uw opname door [deze link te volgen]({download_link})*\n" ), hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]", document_default_title="Transcriptie", diff --git a/src/summary/tests/__init__.py b/src/summary/tests/__init__.py deleted file mode 100644 index 224e5ad5..00000000 --- a/src/summary/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the summary service.""" diff --git a/src/summary/tests/api/__init__.py b/src/summary/tests/api/__init__.py deleted file mode 100644 index bce0b500..00000000 --- a/src/summary/tests/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the API summary service.""" diff --git a/src/summary/tests/api/test_api_health.py b/src/summary/tests/api/test_api_health.py deleted file mode 100644 index 0460b1d8..00000000 --- a/src/summary/tests/api/test_api_health.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Integration tests for the health check endpoints.""" - - -class TestHeartbeat: - """Tests for the /__heartbeat__ endpoint.""" - - def test_returns_200(self, client): - """The heartbeat endpoint responds with 200 OK without a token.""" - response = client.get("/__heartbeat__") - - assert response.status_code == 200 - - -class TestLBHeartbeat: - """Tests for the /__lbheartbeat__ endpoint.""" - - def test_returns_200(self, client): - """The load-balancer heartbeat endpoint responds with 200 OK without a token.""" - response = client.get("/__lbheartbeat__") - - assert response.status_code == 200 diff --git a/src/summary/tests/api/test_api_tasks.py b/src/summary/tests/api/test_api_tasks.py deleted file mode 100644 index 9492bf95..00000000 --- a/src/summary/tests/api/test_api_tasks.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Integration tests for the API tasks endpoints.""" - -# tests/unit/test_api_tasks.py -from unittest.mock import MagicMock, patch - - -class TestTasks: - """Tests for the /tasks endpoint.""" - - @patch( - "summary.api.route.tasks.process_audio_transcribe_summarize_v2.apply_async", - return_value=MagicMock(id="task-id-abc"), - ) - @patch("summary.api.route.tasks.time.time", return_value=1735725600.0) - def test_create_task_returns_task_id(self, mock_time, mock_apply_async, client): - """POST /tasks/ with valid payload returns id and dispatches Celery task.""" - response = client.post( - "api/v1/tasks/", - headers={"Authorization": "Bearer test-api-token"}, - json={ - "owner_id": "owner-123", - "filename": "recording.mp4", - "email": "user@example.com", - "sub": "sub-123", - "room": "room-abc", - "recording_date": "2026-01-01", - "recording_time": "10:00:00", - "language": None, - "download_link": "http://example.com/file.mp4", - }, - ) - - assert response.status_code == 200 - assert response.json() == {"id": "task-id-abc", "message": "Task created"} - - args = mock_apply_async.call_args.kwargs["args"] - assert args == [ - "owner-123", # owner_id - "recording.mp4", # filename - "user@example.com", # email - "sub-123", # sub - 1735725600.0, # frozen time - "room-abc", # room - "2026-01-01", # recording_date - "10:00:00", # recording_time - None, # language - "http://example.com/file.mp4", # download_link - None, # context_language - ] - - def test_create_task_invalid_language(self, client): - """POST /tasks/ with an unsupported language returns 422.""" - payload = {"language": "klingon"} - response = client.post( - "/api/v1/tasks/", - headers={"Authorization": "Bearer test-api-token"}, - json=payload, - ) - - assert response.status_code == 422 - - @patch( - "summary.api.route.tasks.AsyncResult", - return_value=MagicMock(status="PENDING"), - ) - def test_get_task_status_pending(self, mock_result, client): - """GET /tasks/{id} returns PENDING status when the task has not started yet.""" - response = client.get( - "/api/v1/tasks/task-id-abc", - headers={"Authorization": "Bearer test-api-token"}, - ) - - assert response.status_code == 200 - assert response.json() == {"id": "task-id-abc", "status": "PENDING"} - - @patch( - "summary.api.route.tasks.AsyncResult", - return_value=MagicMock(status="SUCCESS"), - ) - def test_get_task_status_success(self, mock_result, client): - """GET /tasks/{id} returns SUCCESS status when the task has completed.""" - response = client.get( - "/api/v1/tasks/task-id-abc", - headers={"Authorization": "Bearer test-api-token"}, - ) - - assert response.status_code == 200 - assert response.json()["status"] == "SUCCESS" diff --git a/src/summary/tests/conftest.py b/src/summary/tests/conftest.py deleted file mode 100644 index e002c46e..00000000 --- a/src/summary/tests/conftest.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Integration test configuration. Provides shared fixtures.""" - -import pytest -from fastapi.testclient import TestClient -from pydantic import SecretStr - -from summary.core.config import Settings, get_settings -from summary.main import app - - -def get_settings_override(): - """Return settings for tests.""" - return Settings( - app_api_token=SecretStr("test-api-token"), - ) - - -@pytest.fixture() -def client(): - """Provide a FastAPI TestClient for tests.""" - client = TestClient(app) - app.dependency_overrides[get_settings] = get_settings_override - - return client