Compare commits

...

75 Commits

Author SHA1 Message Date
Cyril 7a7c966b3f ️(frontend) restore focus to trigger button when panel closes
improves keyboard navigation and accessibility consistency

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-18 09:44:59 +01:00
Cyril fadf12626d ️(frontend) fix jump and animation break on panel open with auto-focus
used requestAnimationFrame and preventScroll to preserve smooth transition

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-18 09:44:10 +01:00
Cyril 186723a8c3 (frontend) focus transcript and record buttons on open
move keyboard focus to transcript or recording button when the panel opens.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-18 09:44:10 +01:00
Cyril c6c8a84c41 (frontend) add SR announcements for transcript and recording
announce transcript and record events to sr to provide clear feedback

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-18 09:44:09 +01:00
Cyril 3a42a859f4 (frontend) add sr-only class
add a utility class to hide content visually while keeping it available to sr.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-18 09:44:08 +01:00
lebaudantoine a4b76433ab 🧑‍💻(release) introduce a release helper tool
Discussed at lunch with our CTO, enhance tooling
around release preparation. Naive bash script generated
using Claude. Please feel free to enhance it.
2025-12-17 19:55:24 +01:00
lebaudantoine ae863418cd 📝(changelog) reorganize sections to match Keep a Changelog convention
Reorder CHANGELOG section headings to follow standard Keep a Changelog format
(Added, Changed, Deprecated, Removed, Fixed, Security) for consistent structure
that users expect when reviewing release notes.
2025-12-17 18:41:45 +01:00
lebaudantoine dcdae26610 🔖(release) release 1.0.1
Patch several accessibility issues.
2025-12-17 17:36:01 +01:00
Cyril 90c0442d35 (frontend) fix focus scroll jump during side panel animation
preventScroll avoids layout shift that broke the slide-in chat animation

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 16:13:25 +01:00
Cyril 9093371d25 (frontend) restore focus on chat close
restore keyboard focus to the triggering element when the chat panel closes.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 16:13:24 +01:00
Cyril 1d45d3aa7c (frontend) focus chat input on panel open
move keyboard focus to the message input when the chat panel opens.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 16:12:44 +01:00
Cyril fcb89c520e ️(frontend) fix heading level in modal to maintain semantic hierarchy
replaced h3 with h2 for accessibility and proper document structure
2025-12-17 16:00:35 +01:00
Cyril 309ce0989d ️(frontend) indicate external link opens in new window on feedback
added title attribute to clarify link behavior for screen reader users
2025-12-17 15:42:30 +01:00
Cyril a6c154374f ️(frontend) change ptt keybinding from space to v
ptt now uses v key to avoid accidental activation when typing
2025-12-17 15:18:46 +01:00
lebaudantoine b0e27b38e2 🔒️(backend) avoid serializing rooms's pin code when restricted
Prevent anonymous users waiting in the lobby, or attacker
to discover the room pin code, that would allow them to join a room.
2025-12-17 10:05:23 +01:00
Cyril 9bdc68f9c9 (frontend) create reusable shortcut tooltip component
extracted tooltip into a component to unify style and ease reuse across ui

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-16 09:41:43 +01:00
Cyril 4545e9fa1e 💄(frontend) update shortcut tooltip position and style for consistency
moved tooltip from left to right to avoid overlap with recording indicator
2025-12-16 09:41:43 +01:00
Cyril 3f1edbf134 ️(frontend) fix SR texts/translations to avoid double announcement
Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-16 09:41:42 +01:00
Cyril 4f2764eef4 ️(frontend) add tooltip and sr hint for f2 shortcut to bottom toolbar
helps keyboard and sr users discover the f2 shortcut for toolbar access

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:57:51 +01:00
Cyril b11cc6e9da ️(frontend) update blur and focus translations for participants
adds fr/en/de/nl translations for blur and focus accessibility labels

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:57:40 +01:00
Cyril 0a7eb97c90 ️(frontend) hide avatar initials from sr to avoid duplicate names
prevents screen readers from announcing participant names twice

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:57:26 +01:00
Cyril db188075af ️(frontend) improve meeting a11y: blur, focus, hover, sr announcements
enhances keyboard nav and screen reader support for meeting interface

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:56:58 +01:00
lebaudantoine 98e568d63c 🔖(major) release 1.0.0
Wouhou, finally. Important milestone, as our software is used by
thousand of users in production.
2025-12-11 00:18:59 +01:00
lebaudantoine 97e1f7f53f 🔥(changelog) remove outdated unreleased entries from CHANGELOG
Clean up CHANGELOG by removing old unreleased changes that are
no longer relevant or superseded by subsequent work.
2025-12-11 00:18:59 +01:00
lebaudantoine 6022809888 👷(ci) add CI check for CHANGELOG updates in pull requests
Implement automated CI validation ensuring pull request authors
update CHANGELOG with their changes, preventing undocumented
changes from merging and maintaining accurate release
documentation for users and maintainers.
2025-12-11 00:18:59 +01:00
lebaudantoine d241de6af1 🔖(minor) bump release to 0.1.43
- upgrade dependencies for security reason
- handle hallucination in transcription
- minor frontend fixes
- support resource server authentification
2025-12-10 23:16:22 +01:00
Martin Guitteny ad494f5de5 ♻️(summary) refactor transcript formatting into unified handler class
Consolidate scattered transcript formatting functions into single
cohesive class encapsulating all transcript processing logic
for better maintainability and clearer separation of concerns.

Add transcript cleaning step to remove spurious recognition artifacts
like randomly predicted "Vap'n'Roll Thierry" phrases that appear
without corresponding audio, improving transcript quality
by filtering model hallucinations.
2025-12-10 20:40:23 +01:00
lebaudantoine fba879e739 (backend) allow prefixing resource server scopes
When declaring scopes with our OIDC provider, they require us to prefix
each scope with our application name. This is to prevent reserving generic
scopes like rooms:list for only our app, as they manage a large federation.

I’m proposing a workaround where, if a resource server prefix is detected in
the scope, it’s stripped out. This solution is simple and sufficient
in my opinion.

Since the scopes are defined in the database, I don’t want to update
them directly. Additionally, each self-hosted instance may have a different
application name, so the prefix should be configurable via a Django setting.
2025-12-10 19:47:36 +01:00
renovate[bot] cac5595a91 ⬆️(dependencies) update vite to v7.0.8 [SECURITY] 2025-12-10 17:25:55 +01:00
lebaudantoine 78e5c72310 🐛(frontend) prevent invite dialog to show up on mobile
While creating a meeting on mobile, the dialog was opening,
and when its width exceeds the mobile width, users are unable
to close the dialog.

Prevent the dialog opening on mobile as a hot fix.
2025-12-10 12:52:41 +01:00
lebaudantoine 2ab31189f4 🐛(frontend) fix unclickable fullscreen warning buttons
Adjust z-index values to restore button interactivity broken by previous
z-index changes in commit 53e68b7, ensuring fullscreen warning dismiss
controls remain accessible to users instead of being blocked
by overlay layering.
2025-12-10 12:48:24 +01:00
lebaudantoine bb4a863f8d ⬆️(frontend) manually upgrade Alpine dependencies to fix libpng vul
Manually update libexpat to 1.6.53-r0 in Alpine 3.21.3 base image
to address CVE-2025-64720, CVE-2025-65018,
CVE-2025-66293 high-severity vulnerability until newer Alpine base image
becomes available, ensuring Trivy security scans pass.
2025-12-10 12:43:19 +01:00
renovate[bot] 0241f67787 ⬆️(dependencies) update django to v5.2.9 [SECURITY] 2025-12-09 22:18:27 +01:00
lebaudantoine 908bbb828a 📝(backend) add resource server quickstart documentation
Create initial resource server integration documentation based on existing
service account documentation structure to help developers understand
authentication flow and implementation requirements for external services
consuming Meet's protected resources.
2025-11-24 19:50:12 +01:00
lebaudantoine c7f5dabbad (backend) integrate ResourceServerAuthentication on the external api
Upgrade django-lasuite to v0.0.19 to benefit from the latest resource server
authentication backend. Thanks @qbey for your work. For my needs, @qbey
refactored the class in #46 on django-lasuite.

Integrate ResourceServerAuthentication in the relevant viewset. The integration
is straightforward since most heavy lifting was done in the external-api viewset
when introducing the service account.

Slightly modify the existing service account authentication backend to defer to
ResourceServerAuthentication if a token is not recognized.

Override user provisioning behavior in ResourceServerBackend: now, a user is
automatically created if missing, based on the 'sub' claim (email is not yet
present in the introspection response). Note: shared/common implementation
currently only retrieves users, failing if the user does not exist.
2025-11-24 18:23:38 +01:00
lebaudantoine a642c6d9a2 🔧(backend) add Docker network for shared Keycloak OIDC authentication
Define Docker network enabling external service providers to share Keycloak
instance with local development stack, supporting OIDC authentication flow
where services obtain tokens from shared Keycloak then pass to Meet
for introspection and validation.

Prepares Meet infrastructure for multi-service authentication architecture
though external service provider Docker Compose integration changes remain
in separate repository.
2025-11-24 18:23:38 +01:00
lebaudantoine a6dc12d91c 🩹(frontend) avoid unnecessary redirection while authenticating
A manually constructed authentication URL didn’t match the actual endpoint
address, causing the Django backend to issue a 301 redirect to the correct URL.

This wasn’t a problem for regular users at first, but once a client integrating
through a virtual browser came on board, it became significant. The 301 redirect
was disrupting the virtual browser’s cookie/cache system, which in turn broke
the authentication flow.

This change aims to resolve the issue, although it’s not yet certain that
it will fully address their problem.
2025-11-20 10:10:03 +01:00
lebaudantoine 307987d94d 🌐(backend) compile missing translations
I forgot to compile newly added backend translations.
Fix it.
2025-11-15 16:31:07 +01:00
lebaudantoine d7ebdbf401 🔖(minor) bump release to 0.1.42
- add admin action to retry a recording notification to external services
- log more Celery tasks' parameters
- add multilingual support for real-time subtitles
- update backend dependencies
2025-11-14 18:23:22 +01:00
lebaudantoine dad396273c ️(frontend) hide decorative icons from screen readers per issue #730
Mark unnecessary decorative icons as aria-hidden following feedback
from @cyberbaloo to eliminate redundant screen reader announcements
that create noisy and annoying experience for users relying on
assistive technologies.
2025-11-13 18:23:49 +01:00
lebaudantoine 555daedeba 🌐(backend) update translation files with newly introduced strings
Regenerate backend translation files to include missing translations for newly
added translatable strings in recent code changes, ensuring complete
internationalization coverage across all supported languages.
2025-11-13 18:02:49 +01:00
lebaudantoine 0d09d1df08 (backend) fix auth unit test with django-lasuite 0.1.16 update
django-lasuite 0.1.16 changed the user update mechanism from .update()
to .save(), which triggers Django's constraint validation. This causes
an additional SELECT query to verify 'sub' field uniqueness on every
user update, despite 'sub' being immutable in our auth flow.

This commit update the test to make them pass again.
2025-11-13 16:26:17 +01:00
lebaudantoine a40af726b6 📌(backend) pin pylint to 3.x to resolve compatibility conflict
Restrict pylint version to 3.x in renovate configuration because
pylint-django 2.6.1 requires pylint<4, preventing automatic upgrades
to pylint 4.x that would create unresolvable dependency conflicts
until pylint-django releases compatible version.
2025-11-13 16:26:17 +01:00
renovate[bot] f8a37e55b1 ⬆️(dependencies) update python dependencies 2025-11-13 16:26:17 +01:00
lebaudantoine 3baec0a863 ⬆️(backend) upgrade brotli to 1.2.0 to fix CVE-2025-6176
Update brotli compression library to version 1.2.0 addressing
CVE-2025-6176 security vulnerability to maintain secure
compression functionality and pass security scans.
2025-11-13 10:28:10 +01:00
lebaudantoine 5b6ed6bbf0 ⬆️(backend) upgrade Django to 5.2.8 to fix security vulnerabilities
Update Django from previous version to 5.2.8 addressing CVE-2025-64459
and CVE-2025-64458 security vulnerabilities to maintain secure
application infrastructure and pass security audits.
2025-11-13 10:28:10 +01:00
anonymous candidate aea01636cf 👷(ci) use variables in pipeline for docker registry
Introduce new variables for the docker registry where to push docker images on forks:
- DOCKER_CONTAINER_REGISTRY_HOSTNAME for the docker registry hostname, with default value "docker.io"
- DOCKER_CONTAINER_REGISTRY_NAMESPACE for the docker registry namespace, with default value "lasuite"
2025-11-13 09:43:16 +01:00
unteem e4c2b42e4a 📝(self-hosted) add documentation for self-hosting on docker compose
It describes the minimalist LaSuite Meet instance, with the simple
feature of having a room conference.
2025-11-13 09:38:47 +01:00
unteem 36ba0f9c8e 📝(self-hosted) reorganize doc for new installation exmaples
We will introduce in the next commits the compose set-up that also
require examples values/config files. Thus, re-organize the kube ones
to  dedicated folder, to make the files organisation extensible.
2025-11-13 09:38:47 +01:00
Ghislain LE MEUR 2d6fe6ee7d 🔖(helm) release chart 0.0.15
This release adds support for injecting custom Kubernetes
resources through the extraManifests parameter.

New features:
- Add extraManifests support for deploying custom resources
- Support multiple input formats (list, map, raw YAML strings)
- Enable Helm template variables in injected manifests
2025-11-12 14:38:20 +01:00
Ghislain LE MEUR e2fcf7dd2c (helm) add extraManifests support for custom resources
Add ability to inject custom Kubernetes manifests through the
values.yaml file. This allows users to deploy additional
resources (Deployments, Services, ConfigMaps, etc.) without
modifying the chart templates.

The template supports multiple input formats: list of objects,
map of named objects, and raw YAML strings, providing maximum
flexibility for users.

- Create templates/extra-objects.yaml with flexible rendering
- Add extraManifests parameter in values.yaml with documentation
- Support Helm template variables in injected manifests
- Handle list, map, and string YAML formats automatically
2025-11-12 14:38:20 +01:00
Ghislain LE MEUR 9f9cef7e2a (agents) add multilingual support for real-time subtitles
Add dynamic configuration for Deepgram STT via environment variables,
enabling multilingual real-time subtitles with automatic language
detection.

Changes:
- Add DEEPGRAM_STT_* environment variables pattern for configuration
- Implement _build_deepgram_stt_kwargs() to dynamically build STT
  parameters from environment variables
- Add whitelist of supported parameters (model, language) for LiveKit
  Deepgram plugin
- Log warnings for unsupported parameters (diarize, smart_format, etc)
- Set default configuration: model=nova-3, language=multi
- Document supported parameters in Helm values.yaml

Configuration:
- DEEPGRAM_STT_MODEL: Deepgram model (default: nova-3)
- DEEPGRAM_STT_LANGUAGE: Language or 'multi' for automatic detection
  of 10 languages (en, es, fr, de, hi, ru, pt, ja, it, nl)

Note: Advanced features like diarization and smart_format are not
supported by the LiveKit Deepgram plugin in streaming mode.
2025-11-12 11:45:08 +01:00
lebaudantoine b403ac56bf 🚨(summary) disable linter warning too many statements
summarize_transcribe_v2 as now slightly too many statements,
ignore it for now, but I'll reorganize the code asap.
2025-10-23 06:39:12 +02:00
lebaudantoine baf378d53d (backend) add the owner column to the Room Admin view
Enable administrators to easily identify the owners of a room
when possible. Save one precious click and time.
2025-10-23 06:39:12 +02:00
lebaudantoine 990507e3c7 🔊(summary) increase transcription Celery task logging verbosity
Add detailed logging for owner ID, recording metadata, and
processing context in transcription tasks to improve debugging
capabilities.

It was especially important to get the created document id,
so when having trouble with the docs API, I could share
with them the newly created documents being impacted.
2025-10-23 06:39:12 +02:00
lebaudantoine 6cd54f7e1e 🐛(backend) catch all request exceptions in summary service integration
Replace narrow HTTPError handling with broad RequestException
catch to prevent crashes from network failures (ConnectionError),
timeouts (30s exceeded), SSL/TLS errors, and other request failures
that previously caused unhandled exceptions.

Ensures consistent False return and proper logging for all network-related
failures instead of crashing application when summary service
communication encounters infrastructure issues beyond HTTP errors.
2025-10-23 06:39:12 +02:00
lebaudantoine 315d48a501 (backend) add recording mode column to the list display
While helping users, it was such a pain to determine quickly which recording
was indeed a transcription or a video recording.

Added the column to help me, and support team.
The recording / transcription is the most unstable part of the project.
2025-10-23 06:39:12 +02:00
lebaudantoine 2f7b56f918 (backend) add admin action to manually retrigger notifications
Enable administrators to manually retrigger external service notifications
from Django admin for failed or missed notification scenarios,
providing operational control over notification delivery.
2025-10-23 06:39:12 +02:00
lebaudantoine 53e68b7780 🐛(frontend) remove excessive z-index from screenshare warning overlay
Remove 1000 z-index from screenshare warning that was
causing conflicts with reaction menu and reaction displays,
retaining only necessary layering to hide participant
metadata underneath.
2025-10-22 12:00:40 +02:00
lebaudantoine 10eda5c2ea 🔖(minor) bump release to 0.1.41
- fix transcription observability
- introduce auto idle disconnection
2025-10-22 11:04:04 +02:00
lebaudantoine ba3b3fe0ba (frontend) add localStorage persistence for user preference settings
Persist user preference choices across sessions using localStorage
following notification store pattern, eliminating need to reconfigure
disabled features on every meeting join and respecting user's
long-term preference decisions.
2025-10-22 10:04:47 +02:00
lebaudantoine 0c3bcd81c9 ♻️(frontend) refactor notification preferences to use Field switch
Adopt unified switch component pattern for notification preferences to
enable future addition of descriptive text per notification type,
improving consistency and providing clearer explanation capability
for notification behaviors.
2025-10-22 10:04:47 +02:00
lebaudantoine dbc66c2f07 (frontend) add user setting to disable idle disconnect feature
Allow users to opt-out of idle participant disconnection despite
default enforcement, trusting power users who modify this setting
won't forget to disconnect, though accepting risk they may block
maintenance configuration updates.
2025-10-22 10:04:47 +02:00
lebaudantoine 39be4697b0 💄(frontend) add right margin to switch description for better spacing
Add margin between switch description text and toggle button to
improve visual breathing room and prevent text from appearing
cramped against interactive control element.
2025-10-22 10:04:47 +02:00
lebaudantoine 2443fa63a5 (frontend) add idle disconnect warning dialog for LiveKit maintenance
Introduce pop-in alerting participants of automatic 2-minute idle
disconnect to enable LiveKit node configuration updates during
maintenance windows, preventing forgotten tabs from blocking
overnight production updates following patterns
from proprietary videoconference solutions.
2025-10-22 10:04:47 +02:00
lebaudantoine 214dc87b1f (frontend) add narrow "alert" dialog mode for concise messages
Introduce new narrow-width alert dialog variant to improve
readability of short messages by preventing excessively
long line lengths that occur when brief alerts use
standard dialog widths.
2025-10-22 10:04:47 +02:00
lebaudantoine 3dc23be101 (backend) add configuration for idle disconnect timeout
Expose idle disconnect timeout as configurable parameter accepting None value
to disable feature entirely, providing emergency killswitch for buggy behavior
without redeployment, following other frontend configuration patterns.
2025-10-22 10:04:47 +02:00
lebaudantoine 6b5e8081bc 🐛(celery) fix metadata task_args order broken by signal sender argument
Restore correct task_args ordering in metadata manager after commit f0939b6f
added sender argument to Celery signals for transcription task scoping,
unexpectedly shifting positional arguments and breaking metadata creation.

Issue went undetected due to missing staging analytics deployment, silently
losing production observability on microservice without blocking transcription
job execution, highlighting need for staging analytics activation.
2025-10-22 07:17:00 +02:00
lebaudantoine df671ea994 🐛(frontend) posthog-cli 0.5.0 release introduced breaking changes
Posthog-cli version wasn't pinned.
Please check issue #39846, which describe our issue, starting
0.5.0, the cli needs an API token and a Project ID.

Pin to the last stable version we used 0.4.8, and wait a bit
they already released a 0.5.1 that mitigate some of the breaking
change.

I would wait the 0.5.x to be stable and battle tested by other
developpers before switching.

Also as I consider switching the Error tracking to sentry.
2025-10-22 05:48:06 +02:00
lebaudantoine 06a5b9b17e 🩹(doc) fix wrong endpoint path
Applications to application in the application/token endpoint.
Spotted by external contributor.
2025-10-22 05:07:02 +02:00
Ghislain LE MEUR 59d4c2583b 🐛(auth) fix LiveKit token authentication field mismatch
Fixes "Invalid LiveKit token" errors caused by field mismatch between
token generation and authentication lookup.

Previously:
- generate_token() used user.sub as token identity
- LiveKitTokenAuthentication tried to retrieve user via user.id field
- This failed when sub was not a UUID (e.g., from LemonLDAP OIDC provider)

Now:
- generate_token() continues using user.sub (canonical OIDC identifier)
- LiveKitTokenAuthentication correctly looks up by sub field
- Both sides now consistently use the same field

This ensures compatibility with all RFC 7519-compliant OIDC providers,
regardless of their sub claim format.
2025-10-20 04:57:02 +02:00
Ghislain LE MEUR 4b80b4ac9f 🔖(helm) release chart 0.0.14
Fix missing image and command attributes for celery workers
2025-10-17 12:18:31 +02:00
Ghislain LE MEUR 96d7a8875b 🐛(helm) add default commands for celery workers
Without explicit commands in values.yaml,
celeryTranscribe and  celerySummarize pods
were using the Dockerfile's default CMD (uvicorn),
which started the REST API instead of Celery workers.

This fix adds default commands to values.yaml for both services,
ensuring they run as Celery workers processing their respective
queues (transcribe-queue and summarize-queue).
2025-10-17 12:18:31 +02:00
Ghislain LE MEUR dc177b69d8 🐛(summary) add image
Add missing image attributes for summary, celerySummarize and celeryTranscribe
2025-10-17 12:18:31 +02:00
Martin Guitteny 36b2156c7b ️(summary) change formating from prompt to response_format
Add ability to use response_format in call function in order to
have better result with albert-large model
Use reponse_format for next steps and plan generation
2025-10-13 12:07:54 +02:00
118 changed files with 3723 additions and 679 deletions
+9 -7
View File
@@ -14,6 +14,8 @@ on:
env:
DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
jobs:
build-and-push-backend:
@@ -27,7 +29,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-backend
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -40,7 +42,7 @@ jobs:
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
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
@@ -63,7 +65,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-frontend
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -76,7 +78,7 @@ jobs:
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
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
@@ -100,7 +102,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-frontend-dinum
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -113,7 +115,7 @@ jobs:
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/meet-frontend-dinum:${{ github.sha }}'
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
@@ -137,7 +139,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-summary
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
+30
View File
@@ -34,6 +34,32 @@ jobs:
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
lint-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
if [ $max_line_length -ge 80 ]; then
echo "ERROR: CHANGELOG has lines longer than 80 characters."
exit 1
fi
build-mails:
runs-on: ubuntu-latest
defaults:
@@ -183,6 +209,10 @@ jobs:
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OIDC_RS_CLIENT_ID: meet
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
OIDC_OP_URL: https://oidc.example.com
steps:
- name: Checkout repository
+11 -2
View File
@@ -1,4 +1,3 @@
# Changelog
All notable changes to this project will be documented in this file.
@@ -9,4 +8,14 @@ and this project adheres to
## [Unreleased]
- 🔧(backend) support `_FILE` for secret environment variables #566
## [1.0.1] - 2025-12-17
### Changes
- ♿(frontend) improve accessibility:
- ♿️(frontend) Improve SR and focus for transcript and recording #810
- ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807
+160
View File
@@ -0,0 +1,160 @@
#!/bin/bash
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
# Function to update npm package version
update_npm_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
npm version "$VERSION" --no-git-tag-version
cd -
}
# Function to update Python project version in pyproject.toml
update_python_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
if [ ! -f "pyproject.toml" ]; then
print_error "pyproject.toml not found in src/$component!"
exit 1
fi
if grep -q '^version = "' pyproject.toml; then
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
rm pyproject.toml.bak
print_info "Updated pyproject.toml version to $VERSION"
else
print_error "Could not find version line in pyproject.toml"
exit 1
fi
cd -
}
# Check if we're in a git repository
if ! git rev-parse --git-dir > /dev/null 2>&1; then
print_error "Not a git repository. Please run this script from the root of your project."
exit 1
fi
# Check if working directory is clean
if ! git diff-index --quiet HEAD --; then
print_error "Working directory is not clean. Please commit or stash your changes first."
exit 1
fi
# Ask user for release version number
echo ""
read -p "Enter release version number (e.g., 1.2.3): " VERSION
# Validate version format (basic semver check)
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
print_error "Invalid version format. Please use semantic versioning (e.g., 1.2.3)"
exit 1
fi
print_info "Release version: $VERSION"
# Check if branch already exists
BRANCH_NAME="release/$VERSION"
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
print_error "Branch $BRANCH_NAME already exists!"
exit 1
fi
# Create and checkout new branch
print_info "Creating branch: $BRANCH_NAME"
git checkout -b "$BRANCH_NAME"
# Update frontend
update_npm_version "frontend"
# Update SDK
update_npm_version "sdk"
# Update mail
update_npm_version "mail"
# Update backend pyproject.toml
update_python_version "backend"
# Update summary pyproject.toml
update_python_version "summary"
# Update agents pyproject.toml
update_python_version "agents"
# Update CHANGELOG
print_info "Updating CHANGELOG..."
if [ ! -f "CHANGELOG.md" ]; then
print_error "CHANGELOG.md not found in project root!"
exit 1
fi
# Get current date in YYYY-MM-DD format
CURRENT_DATE=$(date +%Y-%m-%d)
# Replace [Unreleased] with [version number] - YYYY-MM-DD
if grep -q '\[Unreleased\]' CHANGELOG.md; then
sed -i.bak "s/\[Unreleased\]/[$VERSION] - $CURRENT_DATE/" CHANGELOG.md
# Add new [Unreleased] section after the header
# This adds it after the line containing "Semantic Versioning"
sed -i.bak "/Semantic Versioning/a\\
\\
## [Unreleased]
" CHANGELOG.md
rm CHANGELOG.md.bak
print_info "Updated CHANGELOG.md"
else
print_warning "Could not find [Unreleased] section in CHANGELOG.md"
fi
# Summary
echo ""
print_info "Release preparation complete!"
echo ""
echo "Summary:"
echo " - Branch created: $BRANCH_NAME"
echo " - Version updated to: $VERSION"
echo " - Files modified:"
echo " - src/frontend/package.json"
echo " - src/sdk/package.json"
echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - CHANGELOG.md"
echo ""
print_warning "Next steps:"
echo " 1. Review the changes: git status"
echo " 2. Commit the changes: git add . && git commit -m 'Release $VERSION'"
echo " 3. Push the branch: git push origin $BRANCH_NAME"
echo ""
+10
View File
@@ -90,6 +90,9 @@ services:
- createwebhook
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
networks:
- resource-server
- default
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -145,6 +148,9 @@ services:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- keycloak
networks:
- resource-server
- default
frontend:
user: "${DOCKER_USER:-1000}"
@@ -298,3 +304,7 @@ services:
watch:
- action: rebuild
path: ./src/summary
networks:
default:
resource-server:
+7 -2
View File
@@ -24,7 +24,7 @@ RUN npm run build
# Inject PostHog sourcemap metadata into the built assets
# This metadata is essential for correctly mapping errors to source maps in production
RUN set -e && \
npx @posthog/cli sourcemap inject --directory ./dist/assets
npx @posthog/cli@0.4.8 sourcemap inject --directory ./dist/assets
COPY ./docker/dinum-frontend/dinum-styles.css \
./dist/assets/
@@ -42,7 +42,12 @@ COPY ./docker/dinum-frontend/fonts/ \
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2 libexpat>=2.7.2-r0
RUN apk update && apk upgrade libssl3 \
libcrypto3 \
libxml2>=2.12.7-r2 \
libxslt>=1.1.39-r2 \
libexpat>=2.7.2-r0 \
libpng>=1.6.53-r0
USER nginx
@@ -0,0 +1,50 @@
upstream meet_backend {
server ${BACKEND_HOST}:8000 fail_timeout=0;
}
upstream meet_frontend {
server ${FRONTEND_HOST}:8080 fail_timeout=0;
}
server {
listen 8083;
server_name localhost;
charset utf-8;
# Disables server version feedback on pages and in headers
server_tokens off;
proxy_ssl_server_name on;
location @proxy_to_meet_backend {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://meet_backend;
}
location @proxy_to_meet_frontend {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://meet_frontend;
}
location / {
try_files $uri @proxy_to_meet_frontend;
}
location /api {
try_files $uri @proxy_to_meet_backend;
}
location /admin {
try_files $uri @proxy_to_meet_backend;
}
location /static {
try_files $uri @proxy_to_meet_backend;
}
}
+23
View File
@@ -0,0 +1,23 @@
version: '3'
# You can add any necessary service here that will join the same docker network
# sharing keycloak. Services added to the 'meet_resource-server' network will be
# able to communicate with keycloak and the backend on that network.
services:
# busybox service is only used for testing purposes. It provides curl to test
# connectivity to the backend and keycloak services. Replace this with your
# relevant application services that need to communicate with keycloak.
busybox:
image: alpine:latest
privileged: true
command: sh -c "apk add --no-cache curl && sleep infinity"
stdin_open: true
tty: true
networks:
- default
- meet_resource-server
networks:
default: {}
meet_resource-server:
external: true
+88
View File
@@ -0,0 +1,88 @@
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/postgresql
- env.d/common
volumes:
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:5
backend:
image: lasuite/meet-backend:latest
user: ${DOCKER_USER:-1000}
restart: always
env_file:
- env.d/common
- env.d/backend
- env.d/postgresql
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 15s
timeout: 30s
retries: 20
start_period: 10s
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
livekit:
condition: service_started
frontend:
image: lasuite/meet-frontend:latest
user: "${DOCKER_USER:-1000}"
entrypoint:
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
env_file:
- env.d/common
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=${MEET_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${MEET_HOST} # used by lets encrypt to generate TLS certificate
depends_on:
backend:
condition: service_healthy
volumes:
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
livekit:
image: livekit/livekit-server:latest
command: --config /config.yaml
ports:
- 7881:7881/tcp
- 7882:7882/udp
volumes:
- ./livekit-server.yaml:/config.yaml
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=${LIVEKIT_HOST} # used by nginx proxy
# - VIRTUAL_PORT=7880 # used by nginx proxy
# - LETSENCRYPT_HOST=${LIVEKIT_HOST} # used by lets encrypt to generate TLS certificate
depends_on:
redis:
condition: service_started
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
+92
View File
@@ -0,0 +1,92 @@
# Deploy and Configure Keycloak for Meet
## Installation
> [!CAUTION]
> We provide those instructions as an example, for production environments, you should follow the [official documentation](https://www.keycloak.org/documentation).
### Step 1: Prepare your working environment:
```bash
mkdir keycloak/env.d && cd keycloak
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
```
### Step 2:. Update `env.d/` files
The following variables need to be updated with your own values, others can be left as is:
```env
POSTGRES_PASSWORD=<generate postgres password>
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
```
### Step 3: Expose keycloak instance on https
> [!NOTE]
> You can skip this section if you already have your own setup.
To access your Keycloak instance on the public network, it needs to be exposed on a domain with SSL termination. You can use our [example with nginx proxy and Let's Encrypt companion](../nginx-proxy/README.md) for automated creation/renewal of certificates using [acme.sh](http://acme.sh).
If following our example, uncomment the environment and network sections in compose file and update it with your values.
```yaml
version: '3'
services:
keycloak:
...
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
...
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
```
### Step 4: Start the service
```bash
`docker compose up -d`
```
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
### Step 1: Create a New Realm
1. Log in to the Keycloak administration console.
2. Navigate to the realm tab and click on the "Create realm" button.
3. Enter the name of the realm - `meet`.
4. Click "Create".
#### Step 2: Create a New Client
1. Navigate to the "Clients" tab.
2. Click on the "Create client" button.
3. Enter the client ID - e.g. `meet`.
4. Enable "Client authentication" option.
6. Set the "Valid redirect URIs" to the URL of your meet application suffixed with `/*` - e.g., "https://meet.example.com/*".
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
1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret.
@@ -0,0 +1,36 @@
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/kc_postgresql
volumes:
- ./data/keycloak:/var/lib/postgresql/data/pgdata
keycloak:
image: quay.io/keycloak/keycloak:latest
command: ["start"]
env_file:
- env.d/kc_postgresql
- env.d/keycloak
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
depends_on:
postgresql:
condition: service_healthy
restart: true
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
#
#networks:
# proxy-tier:
# external: true
@@ -0,0 +1,39 @@
# Nginx proxy with automatic SSL certificates
> [!CAUTION]
> We provide those instructions as an example, for extended development or production environments, you should follow the [official documentation](https://github.com/nginx-proxy/acme-companion/tree/main/docs).
Nginx-proxy sets up a container running nginx and docker-gen. docker-gen generates reverse proxy configs for nginx and reloads nginx when containers are started and stopped.
Acme-companion is a lightweight companion container for nginx-proxy. It handles the automated creation, renewal and use of SSL certificates for proxied Docker containers through the ACME protocol.
## Installation
### Step 1: Prepare your working environment:
```bash
mkdir nginx-proxy && cd nginx-proxy
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
```
### Step 2: Edit `DEFAULT_EMAIL` in the compose file.
Albeit optional, it is recommended to provide a valid default email address through the `DEFAULT_EMAIL` environment variable, so that Let's Encrypt can warn you about expiring certificates and allow you to recover your account.
### Step 3: Create docker network
Containers need share the same network for auto-discovery.
```bash
docker network create proxy-tier
```
### Step 4: Start service
```bash
docker compose up -d
```
## Usage
Once both nginx-proxy and acme-companion containers are up and running, start any container you want proxied with environment variables `VIRTUAL_HOST` and `LETSENCRYPT_HOST` both set to the domain(s) your proxied container is going to use.
@@ -0,0 +1,36 @@
services:
nginx-proxy:
image: nginxproxy/nginx-proxy
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
volumes:
- html:/usr/share/nginx/html
- certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro
networks:
- proxy-tier
acme-companion:
image: nginxproxy/acme-companion
container_name: nginx-proxy-acme
environment:
- DEFAULT_EMAIL=mail@yourdomain.tld
volumes_from:
- nginx-proxy
volumes:
- certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- proxy-tier
networks:
proxy-tier:
external: true
volumes:
html:
certs:
acme:
+22
View File
@@ -0,0 +1,22 @@
port: 7880
redis:
address: redis:6379
keys:
meet: <your livekit secret key>
# WebRTC configuration
rtc:
# # when set, LiveKit will attempt to use a UDP mux so all UDP traffic goes through
# # listed port(s). To maximize system performance, we recommend using a range of ports
# # greater or equal to the number of vCPUs on the machine.
# # port_range_start & end must not be set for this config to take effect
udp_port: 7882
# when set, LiveKit enable WebRTC ICE over TCP when UDP isn't available
# this port *cannot* be behind load balancer or TLS, and must be exposed on the node
# WebRTC transports are encrypted and do not require additional encryption
# only 80/443 on public IP are allowed if less than 1024
tcp_port: 7881
# use_external_ip should be set to true for most cloud environments where
# the host has a public IP address, but is not exposed to the process.
# LiveKit will attempt to use STUN to discover the true IP, and advertise
# that IP with its clients
use_external_ip: true
+1 -4
View File
@@ -6,12 +6,9 @@ Here are a bunch of resources to help you install the project.
La Suite Meet maintainers use only the Kubernetes deployment method in production, so advanced support is available exclusively for this setup. Please follow the instructions provided [here](/docs/installation/kubernetes.md).
## Docker Compose
We understand that not everyone has a Kubernetes cluster available.
We understand that not everyone has a Kubernetes cluster available, please follow the instructions provided [here](/docs/installation/compose.md) to set up a docker compose instance.
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
> [!WARNING]
> Under construction: A PR is in progress to support deploying La Suite Meet via Docker Compose.
## Other ways to install La Suite Meet
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
+227
View File
@@ -0,0 +1,227 @@
# Installation with docker compose
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
## Requirements
All services are required to run the minimalist instance of LaSuite Meet. Click the links for ready-to-use configuration examples:
| Service | Purpose | Example Config |
|-------------------|---------|----------------------------------------------------------|
| **PostgreSQL** | Main database | [compose.yaml](../examples/compose/compose.yaml) |
| **Redis** | Cache & sessions | [compose.yaml](../examples/compose/compose.yaml) |
| **Livekit** | Real-time communication | [compose.yaml](../examples/compose/compose.yaml) |
| **OIDC Provider** | User authentication | [Keycloak setup](../examples/compose/keycloak/README.md) |
| **SMTP Service** | Email notifications | - |
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (MinIO, email). See `/features` folder for details.
## Software Requirements
Ensure you have Docker Compose(v2) installed on your host server. Follow the official guidelines for a reliable setup:
Docker Compose is included with Docker Engine:
- **Docker Engine:** We suggest adhering to the instructions provided by Docker
for [installing Docker Engine](https://docs.docker.com/engine/install/).
For older versions of Docker Engine that do not include Docker Compose:
- **Docker Compose:** Install it as per the [official documentation](https://docs.docker.com/compose/install/).
> [!NOTE]
> `docker-compose` may not be supported. You are advised to use `docker compose` instead.
## Step 1: Prepare your working environment:
```bash
mkdir -p meet/env.d && cd meet
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/compose.yaml
curl -o .env https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/hosts
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/common
curl -o env.d/postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/postgresql
curl -o livekit-server.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/livekit/server.yaml
curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docker/files/production/default.conf.template
```
## Step 2: Configuration
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
In this example, we assume the following services:
- OIDC provider on https://id.yourdomain.tld
- Livekit server on https://livekit.yourdomain.tld
- Meet server on https://meet.yourdomain.tld
**Set your own values in `.env`**
### OIDC
Authentication in Meet is managed through Open ID Connect protocol. A functional Identity Provider implementing this protocol is required.
For guidance, refer to our [Keycloak deployment example](../examples/compose/keycloak/README.md).
If using Keycloak as your Identity Provider, in `env.d/common` set `OIDC_RP_CLIENT_ID` and `OIDC_RP_CLIENT_SECRET` variables with those of the OIDC client created for Meet. By default we have set `meet` as the realm name, if you have named your realm differently, update the value `REALM_NAME` in `.env`
For others OIDC providers, update the variables in `env.d/common`.
### Postgresql
Meet uses PostgreSQL as its database. Although an external PostgreSQL can be used, our example provides a deployment method.
If you are using the example provided, you need to generate a secure key for `DB_PASSWORD` and set it in `env.d/postgresql`.
If you are using an external service or not using our default values, you should update the variables in `env.d/postgresql`
### Redis
Meet uses Redis for caching and inter-service communication. While an external Redis can be used, our example provides a deployment method.
If you are using an external service, you need to set `REDIS_URL` environment variable in `env.d/common`.
### Livekit
[LiveKit](https://github.com/livekit/livekit) server is used as the WebRTC SFU (Selective Forwarding Unit) allowing multi-user conferencing. For more information, head to [livekit documentation](https://docs.livekit.io/home/self-hosting/).
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
We provide a minimal recommanded config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
> [!NOTE]
> In this example, we configured multiplexing on a single UDP port. For better performances, you can configure a range of UDP ports.
### Meet
The Meet backend is built on the Django Framework.
Generate a [secure key](https://docs.djangoproject.com/en/5.2/ref/settings/#secret-key.) for `DJANGO_SECRET_KEY` in `env.d/common`.
### Mail
The following environment variables are required in `env.d/common` for the mail service to send invitations :
```env
DJANGO_EMAIL_HOST=<smtp host>
DJANGO_EMAIL_HOST_USER=<smtp user>
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
DJANGO_EMAIL_PORT=<smtp port>
DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
```
## Step 3: Configure your firewall
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
The following ports will need to be opened:
- 80/tcp - for TLS issuance
- 443/tcp - for listening on HTTPS and TURN/TLS packets
- 7881/tcp - WebRTC ICE over TCP
- 7882/udp - for WebRTC multiplexing over UDP
If you are using ufw, enter the follwoing:
```
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 443/udp
ufw allow 7881/tcp
ufw allow 7882/udp
ufw enable
```
## Step 4: Reverse proxy and SSL/TLS
> [!WARNING]
> In a production environment, configure SSL/TLS termination to run your instance on https.
If you have your own certificates and proxy setup, you can skip this part.
You can follow our [nginx proxy example](../examples/compose/nginx-proxy/README.md) with automatic generation and renewal of certificate with Let's Encrypt.
You will need to uncomment the environment and network sections in compose file and update it with your values.
```yaml
frontend:
...
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=${MEET_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${MEET_HOST} # used by lets encrypt to generate TLS certificate
...
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
...
# environment:
# - VIRTUAL_HOST=${LIVEKIT_HOST} # used by nginx proxy
# - VIRTUAL_PORT=7880 # used by nginx proxy
# - LETSENCRYPT_HOST=${LIVEKIT_HOST} # used by lets encrypt to generate TLS certificate
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
#networks:
# proxy-tier:
# external: true
```
## Step 5: Start Meet
You are ready to start your Meet application !
```bash
docker compose up -d
```
> [!NOTE]
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image.
## Step 6: Run the database migration and create Django admin user
```bash
docker compose run --rm backend python manage.py migrate
docker compose run --rm backend python manage.py createsuperuser --email <admin email> --password <admin password>
```
Replace `<admin email>` with the email of your admin user and generate a secure password.
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
THe admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
## How to upgrade your Meet application
Before running an upgrade you must check the [Upgrade document](../../UPGRADE.md) for specific procedures that might be needed.
You can also check the [Changelog](../../CHANGELOG.md) for brief summary of the changes.
### Step 1: Edit the images tag with the desired version
### Step 2: Pull the images
```bash
docker compose pull
```
### Step 3: Restart your containers
```bash
docker compose restart
```
### Step 4: Run the database migration
Your database schema may need to be updated, run:
```bash
docker compose run --rm backend python manage.py migrate
```
+3 -3
View File
@@ -7,7 +7,7 @@ info:
#### Authentication Flow
1. Exchange application credentials for a JWT token via `/external-api/v1.0/applications/token`.
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token`.
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
3. Tokens are scoped and allow applications to act on behalf of specific users.
@@ -40,7 +40,7 @@ tags:
description: Room management operations
paths:
/applications/token:
/application/token:
post:
tags:
- Authentication
@@ -283,7 +283,7 @@ components:
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/applications/token` endpoint.
JWT token obtained from the `/application/token` endpoint.
Include in requests as: `Authorization: Bearer <token>`
schemas:
+393
View File
@@ -0,0 +1,393 @@
openapi: 3.0.3
info:
title: Meet External API
version: 1.0.0
description: |
External API for room management with resource server authentication.
[[description by Oauth 2.0]](https://www.oauth.com/oauth2-servers/the-resource-server/)
#### Authentication Flow
1. Authenticate with the authorization server using your credentials
2. During authentication, request the scopes you need: `lasuite_visio` (mandatory) plus action-specific scopes
3. Receive an access token and a refresh token that includes the requested scopes
4. Use the access token in the `Authorization: Bearer <token>` header for all API requests
5. When the access token expires, use the refresh token to obtain a new access token without re-authenticating
#### Scopes
* `lasuite_visio` - **Mandatory** Base scope required for any API access
* `lasuite_visio:rooms:list` List rooms accessible to the delegated user.
* `lasuite_visio:rooms:retrieve` Retrieve details of a specific room.
* `lasuite_visio:rooms:create` Create new rooms.
* `lasuite_visio:rooms:update` **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `lasuite_visio:rooms:delete` **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
contact:
name: API Support
email: antoine.lebaud@mail.numerique.gouv.fr
servers:
- url: https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0
description: Sandbox server
tags:
- name: Rooms
description: Room management operations
paths:
/rooms:
get:
tags:
- Rooms
summary: List rooms
description: |
Returns a list of rooms accessible to the authenticated user.
Only rooms where the delegated user has access will be returned.
operationId: listRooms
security:
- BearerAuth: [rooms:list]
parameters:
- name: page
in: query
description: Page number for pagination
schema:
type: integer
minimum: 1
default: 1
- name: page_size
in: query
description: Number of items per page
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
'200':
description: List of accessible rooms
content:
application/json:
schema:
type: object
properties:
count:
type: integer
description: Total number of rooms
next:
type: string
nullable: true
description: URL to next page
previous:
type: string
nullable: true
description: URL to previous page
results:
type: array
items:
$ref: '#/components/schemas/Room'
examples:
roomList:
summary: Paginated room list
value:
count: 2
next: "https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0/rooms?page=2"
previous: null
results:
- id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug: "aae-erez-aaz"
access_level: "trusted"
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
telephony:
enabled: true
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
post:
tags:
- Rooms
summary: Create a room
description: |
Creates a new room with secure defaults for external API usage.
**Restrictions:**
- Rooms are always created with `trusted` access (no public rooms via API)
- Room access_level can be updated from the webapp interface.
**Defaults:**
- Delegated user is set as owner
- Room slug auto-generated for uniqueness
- Telephony PIN auto-generated when enabled
- Creation tracked with application client_id for auditing
operationId: createRoom
security:
- BearerAuth: [rooms:create]
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/RoomCreate'
examples:
emptyBody:
summary: No parameters (default)
value: {}
responses:
'201':
description: Room created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
/rooms/{id}:
get:
tags:
- Rooms
summary: Retrieve a room
description: Get detailed information about a specific room by its ID
operationId: retrieveRoom
security:
- BearerAuth: [rooms:retrieve]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
responses:
'200':
description: Room details
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
examples:
room:
summary: Room details
value:
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug: "aae-erez-aaz"
access_level: "trusted"
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
telephony:
enabled: true
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/application/token` endpoint.
Include in requests as: `Authorization: Bearer <token>`
schemas:
TokenRequest:
type: object
required:
- client_id
- client_secret
- grant_type
- scope
properties:
client_id:
type: string
description: Application client identifier
example: "550e8400-e29b-41d4-a716-446655440000"
client_secret:
type: string
format: password
writeOnly: true
description: Application secret key
example: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type:
type: string
enum:
- client_credentials
description: OAuth2 grant type (must be 'client_credentials')
example: "client_credentials"
scope:
type: string
format: email
description: |
Email address of the user to delegate.
The application will act on behalf of this user.
Note: This parameter is named 'scope' to align with OAuth2 conventions,
but accepts an email address to identify the user. This design allows
for future extensibility.
example: "user@example.com"
TokenResponse:
type: object
properties:
access_token:
type: string
description: JWT access token
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
token_type:
type: string
description: Token type (always 'Bearer')
example: "Bearer"
expires_in:
type: integer
description: Token lifetime in seconds
example: 3600
scope:
type: string
description: Space-separated list of granted permission scopes
example: "rooms:list rooms:retrieve rooms:create"
RoomCreate:
type: object
description: Empty object - all room properties are auto-generated
properties: {}
Room:
type: object
properties:
id:
type: string
format: uuid
readOnly: true
description: Unique room identifier
example: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug:
type: string
readOnly: true
description: URL-friendly room identifier (auto-generated)
example: "aze-eere-zer"
access_level:
type: string
readOnly: true
description: Room access level (always 'trusted' for API-created rooms)
example: "trusted"
url:
type: string
format: uri
readOnly: true
description: Full URL to access the room
example: "https://visio-sandbox.beta.numerique.gouv.fr/aze-eere-zer"
telephony:
type: object
readOnly: true
description: Telephony dial-in information (if enabled)
properties:
enabled:
type: boolean
description: Whether telephony is available
example: true
pin_code:
type: string
description: PIN code for dial-in access
example: "123456"
phone_number:
type: string
description: Phone number to dial
example: "+1-555-0100"
default_country:
type: string
description: Default country code
example: "US"
OAuthError:
type: object
description: OAuth2-compliant error response
properties:
error:
type: string
description: Human-readable error description
example: "Invalid credentials"
Error:
type: object
properties:
detail:
type: string
description: Error message
example: "Invalid token."
ValidationError:
type: object
properties:
field_name:
type: array
items:
type: string
description: List of validation errors for this field
example: ["This field is required."]
responses:
UnauthorizedError:
description: The access token is expired, revoked, malformed, or invalid for other reasons. The client can obtain a new access token and try again.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
invalidToken:
summary: Invalid token
value:
error: "Invalid token."
ForbiddenError:
description: Insufficient scope for this operation
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
insufficientScope:
summary: Missing required scope
value:
detail: "Insufficient permissions. Required scope: 'rooms:xxxx'"
RoomNotFoundError:
description: Room not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
roomNotFound:
summary: Room does not exist
value:
detail: "Not found."
BadRequestError:
description: Invalid request data
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
examples:
validationError:
summary: Field validation failed
value:
scope: ["Invalid email address."]
+5
View File
@@ -32,6 +32,8 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/cert
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token/introspect
OIDC_OP_URL=http://localhost:8083/realms/meet
OIDC_RP_CLIENT_ID=meet
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
@@ -45,6 +47,9 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
OIDC_RS_CLIENT_ID=meet
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
# Livekit Token settings
LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
+1 -1
View File
@@ -15,7 +15,7 @@ WHISPERX_DEFAULT_LANGUAGE="fr"
LLM_BASE_URL="https://configure-your-url.com"
LLM_API_KEY="dev-apikey"
LLM_MODEL="Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
LLM_MODEL="albert-large"
WEBHOOK_API_TOKEN="secret"
WEBHOOK_URL="https://configure-your-url.com"
+50
View File
@@ -0,0 +1,50 @@
# Django
DJANGO_ALLOWED_HOSTS=${MEET_HOST}
DJANGO_SECRET_KEY=<generate a secret key>
DJANGO_SETTINGS_MODULE=meet.settings
DJANGO_CONFIGURATION=Production
# Python
PYTHONPATH=/app
# Meet settings
# Mail
DJANGO_EMAIL_HOST=<smtp host>
DJANGO_EMAIL_HOST_USER=<smtp user>
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
DJANGO_EMAIL_PORT=<smtp port>
DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
# Backend url
MEET_BASE_URL="https://${MEET_HOST}"
# OIDC
OIDC_OP_JWKS_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID=<client_id>
OIDC_RP_CLIENT_SECRET=<client secret>
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
LOGIN_REDIRECT_URL=https://${MEET_HOST}
LOGIN_REDIRECT_URL_FAILURE=https://${MEET_HOST}
LOGOUT_REDIRECT_URL=https://${MEET_HOST}
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${MEET_HOST}"]
# Livekit Token settings
LIVEKIT_API_SECRET=<generate a secret key>
LIVEKIT_API_KEY=meet
LIVEKIT_API_URL=https://${LIVEKIT_HOST}
ALLOW_UNREGISTERED_ROOMS=False
+7
View File
@@ -0,0 +1,7 @@
MEET_HOST=meet.domain.tld
KEYCLOAK_HOST=id.domain.tld
LIVEKIT_HOST=livekit.domain.tld
BACKEND_INTERNAL_HOST=backend
FRONTEND_INTERNAL_HOST=frontend
LIVEKIT_INTERNAL_HOST=livekit
REALM_NAME=meet
+13
View File
@@ -0,0 +1,13 @@
# Postgresql db container configuration
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=<generate postgres password>
PGDATA=/var/lib/postgresql/data/pgdata
# Keycloak postgresql configuration
KC_DB=postgres
KC_DB_SCHEMA=public
KC_DB_URL_HOST=postgresql
KC_DB_NAME=${POSTGRES_DB}
KC_DB_USER=${POSTGRES_USER}
KC_DB_PASSWORD=${POSTGRES_PASSWORD}
+8
View File
@@ -0,0 +1,8 @@
# Keycloak admin user
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
# Keycloak configuration
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
KC_PROXY_HEADERS=xforwarded # in this example we are running behind an nginx proxy
KC_HTTP_ENABLED=true # in this example we are running behind an nginx proxy
+11
View File
@@ -0,0 +1,11 @@
# App database configuration
DB_HOST=postgresql
DB_NAME=meet
DB_USER=meet
DB_PASSWORD=<generate a secure password>
DB_PORT=5432
# Postgresql db container configuration
POSTGRES_DB=meet
POSTGRES_USER=meet
POSTGRES_PASSWORD=${DB_PASSWORD}
+6
View File
@@ -9,6 +9,12 @@
"matchManagers": ["pep621"],
"matchPackageNames": ["redis"]
},
{
"groupName": "allowed pylint versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
+58 -1
View File
@@ -29,15 +29,72 @@ logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
# Default Deepgram STT configuration
DEEPGRAM_STT_DEFAULTS = {
"model": "nova-3",
"language": "multi",
}
# Supported parameters for LiveKit's deepgram.STT() in streaming mode
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
# detect_language is NOT supported for real-time streaming
# Use language="multi" instead for automatic multilingual support
DEEPGRAM_STT_SUPPORTED_PARAMS = {
"model",
"language",
}
def _build_deepgram_stt_kwargs():
"""Build Deepgram STT kwargs from DEEPGRAM_STT_* environment variables.
Only parameters supported by LiveKit's deepgram.STT() are included.
Unsupported parameters are logged as warnings.
"""
stt_kwargs = DEEPGRAM_STT_DEFAULTS.copy()
# Scan environment variables for DEEPGRAM_STT_* pattern
for key, value in os.environ.items():
if key.startswith("DEEPGRAM_STT_"):
# Extract parameter name and convert to lowercase
param_name = key.replace("DEEPGRAM_STT_", "", 1).lower()
# Check if parameter is supported by LiveKit plugin
if param_name not in DEEPGRAM_STT_SUPPORTED_PARAMS:
supported = ", ".join(sorted(DEEPGRAM_STT_SUPPORTED_PARAMS))
logger.warning(
f"Ignoring unsupported Deepgram STT parameter: {param_name}. "
f"Supported parameters: {supported}"
)
continue
# Parse value type
value_lower = value.lower()
if value_lower in ("true", "false"):
# Boolean values
stt_kwargs[param_name] = value_lower == "true"
elif value.isdigit():
# Integer values
stt_kwargs[param_name] = int(value)
else:
# String values
stt_kwargs[param_name] = value
logger.info(f"Deepgram STT configuration: {stt_kwargs}")
return stt_kwargs
class Transcriber(Agent):
"""Create a transcription agent for a specific participant."""
def __init__(self, *, participant_identity: str):
"""Init transcription agent."""
# Build STT configuration from environment variables
stt_kwargs = _build_deepgram_stt_kwargs()
super().__init__(
instructions="not-needed",
stt=deepgram.STT(),
stt=deepgram.STT(**stt_kwargs),
)
self.participant_identity = participant_identity
+6 -6
View File
@@ -1,18 +1,18 @@
[project]
name = "agents"
version = "0.1.40"
version = "1.0.1"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.2.6",
"livekit-plugins-deepgram==1.2.6",
"livekit-plugins-silero==1.2.6",
"python-dotenv==1.1.1"
"livekit-agents==1.2.18",
"livekit-plugins-deepgram==1.2.18",
"livekit-plugins-silero==1.2.18",
"python-dotenv==1.2.1"
]
[project.optional-dependencies]
dev = [
"ruff==0.12.0",
"ruff==0.14.4",
]
[build-system]
+83 -3
View File
@@ -1,10 +1,12 @@
"""Admin classes and registrations for core app."""
from django import forms
from django.contrib import admin
from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from core.recording.event import notification
from . import models
@@ -109,10 +111,27 @@ class RoomAdmin(admin.ModelAdmin):
inlines = (ResourceAccessInline,)
search_fields = ["name", "slug", "=id"]
list_display = ["name", "slug", "access_level", "created_at"]
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_owner(self, obj):
"""Return the owner of the room for display in the admin list."""
owners = [
access
for access in obj.accesses.all()
if access.role == models.RoleChoices.OWNER
]
if not owners:
return _("No owner")
if len(owners) > 1:
return _("Multiple owners")
return str(owners[0].user)
class RecordingAccessInline(admin.TabularInline):
"""Inline admin class for recording accesses."""
@@ -121,15 +140,76 @@ class RecordingAccessInline(admin.TabularInline):
extra = 0
@admin.action(description=_("Resend notification to external service"))
def resend_notification(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Resend notification to external service for selected recordings."""
notification_service = notification.NotificationService()
processed = 0
skipped = 0
failed = 0
for recording in queryset:
if recording.is_expired:
skipped += 1
continue
try:
success = notification_service.notify_external_services(recording)
if success:
processed += 1
else:
failed += 1
modeladmin.message_user(
request,
_("Failed to notify for recording %(id)s") % {"id": recording.id},
level=messages.ERROR,
)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-except
failed += 1
modeladmin.message_user(
request,
_("Failed to notify for recording %(id)s: %(error)s")
% {"id": recording.id, "error": str(e)},
level=messages.ERROR,
)
if processed > 0:
modeladmin.message_user(
request,
_("Successfully sent notifications for %(count)s recording(s).")
% {"count": processed},
level=messages.SUCCESS,
)
if skipped > 0:
modeladmin.message_user(
request,
_("Skipped %(count)s expired recording(s).") % {"count": skipped},
level=messages.WARNING,
)
@admin.register(models.Recording)
class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
list_display = ("id", "status", "room", "get_owner", "created_at", "worker_id")
list_display = (
"id",
"status",
"mode",
"room",
"get_owner",
"created_at",
"worker_id",
)
list_filter = ["status", "room", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
actions = [resend_notification]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
+2
View File
@@ -159,6 +159,8 @@ class RoomSerializer(serializers.ModelSerializer):
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
)
else:
del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
+1 -1
View File
@@ -30,7 +30,7 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
raise exceptions.AuthenticationFailed("Token missing user identity")
try:
user = UserModel.objects.get(id=user_id)
user = UserModel.objects.get(sub=user_id)
except UserModel.DoesNotExist:
user = AnonymousUser()
@@ -4,8 +4,10 @@ import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import jwt
import jwt as pyJwt
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from rest_framework import authentication, exceptions
User = get_user_model()
@@ -25,9 +27,11 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
Returns:
Tuple of (user, payload) if authentication successful, None otherwise
"""
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != b"bearer":
# Defer to next authentication backend
return None
if len(auth_header) != 2:
@@ -45,6 +49,8 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.
If token is invalid, defer to next authentication backend.
Args:
token: JWT token string
@@ -52,29 +58,29 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
Tuple of (user, payload)
Raises:
AuthenticationFailed: If token is invalid, expired, or user not found
AuthenticationFailed: If token is expired, or user not found
"""
# Decode and validate JWT
try:
payload = jwt.decode(
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 jwt.ExpiredSignatureError as e:
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except jwt.InvalidIssuerError as e:
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except jwt.InvalidAudienceError as e:
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except jwt.InvalidTokenError as e:
logger.warning("Invalid JWT token: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
return None
user_id = payload.get("user_id")
client_id = payload.get("client_id")
@@ -107,3 +113,55 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
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."""
def get_or_create_user(self, access_token, id_token, payload):
"""Get or create user from OIDC token claims.
Despite the LaSuiteBackend's method name suggesting "get_or_create",
its implementation only performs a GET operation.
Create new user from the sub claim.
Args:
access_token: The access token string
id_token: The ID token string (unused)
payload: Token payload dict (unused)
Returns:
User instance
Raises:
SuspiciousOperation: If user info validation fails
"""
sub = payload.get("sub")
if sub is None:
message = "User info contained no recognizable user identification"
logger.debug(message)
raise SuspiciousOperation(message)
user = self.get_user(access_token, id_token, payload)
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
return user
def create_user(self, sub):
"""Create new user from subject claim.
Args:
sub: Subject identifier from token
Returns:
Newly created User instance
"""
user = self.UserModel(sub=sub)
user.set_unusable_password()
user.save()
return user
@@ -3,6 +3,8 @@
import logging
from typing import Dict
from django.conf import settings
from rest_framework import exceptions, permissions
from .. import models
@@ -55,6 +57,12 @@ class BaseScopePermission(permissions.BasePermission):
if isinstance(token_scopes, str):
token_scopes = token_scopes.split()
if settings.OIDC_RS_SCOPES_PREFIX:
token_scopes = [
scope.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
for scope in token_scopes
]
if required_scope not in token_scopes:
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
+5 -1
View File
@@ -9,6 +9,7 @@ from django.core.exceptions import 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 (
exceptions as drf_exceptions,
@@ -149,7 +150,10 @@ class RoomViewSet(
- create: Create a new room owned by the user (requires 'rooms:create' scope)
"""
authentication_classes = [authentication.ApplicationJWTAuthentication]
authentication_classes = [
authentication.ApplicationJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
]
@@ -158,9 +158,9 @@ class NotificationService:
timeout=30,
)
response.raise_for_status()
except requests.HTTPError as exc:
except requests.RequestException as exc:
logger.exception(
"Summary service HTTP error for recording %s. URL: %s. Exception: %s",
"Summary service error for recording %s. URL: %s. Exception: %s",
recording.id,
settings.SUMMARY_SERVICE_ENDPOINT,
exc,
@@ -345,7 +345,9 @@ def test_authentication_getter_existing_user_change_fields(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
# Note: .save() triggers uniqueness validation queries for unique fields,
# adding extra SELECT queries before the UPDATE (e.g., checking unique=True on 'sub')
with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -32,7 +32,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -52,7 +51,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -71,7 +69,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -88,7 +85,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -105,7 +101,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -347,7 +342,6 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -10,13 +10,14 @@ from django.conf import settings
import jwt
import pytest
import responses
from rest_framework.test import APIClient
from core.factories import (
RoomFactory,
UserFactory,
)
from core.models import ApplicationScope, RoleChoices, Room
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
pytestmark = pytest.mark.django_db
@@ -90,13 +91,29 @@ def test_api_rooms_list_with_expired_token(settings):
assert "expired" in str(response.data).lower()
def test_api_rooms_list_with_invalid_token():
"""Listing rooms with invalid token should return 401."""
@responses.activate
def test_api_rooms_list_with_invalid_token(settings):
"""Listing rooms with invalid token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": False,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
def test_api_rooms_list_missing_scope(settings):
@@ -332,3 +349,221 @@ def test_api_rooms_token_missing_client_id(settings):
assert response.status_code == 401
assert "Invalid token claims." in str(response.data)
@responses.activate
def test_resource_server_creates_user_on_first_authentication(settings):
"""New user should be created during first authentication.
Verifies that the ResourceServerBackend.get_or_create_user() creates a user
in the database when authenticating with a token from an unknown subject (sub).
This tests the user creation workflow during the OIDC introspection process.
"""
with pytest.raises(
User.DoesNotExist,
match="User matching query does not exist.",
):
User.objects.get(sub="very-specific-sub")
assert (
settings.OIDC_RS_BACKEND_CLASS
== "core.external_api.authentication.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 0
db_user = User.objects.get(sub="very-specific-sub")
assert db_user is not None
assert db_user.email is None
@responses.activate
def test_resource_server_skips_user_creation_when_auto_creation_disabled(settings):
"""Verify that ResourceServerBackend respects the user auto-creation setting.
This ensures that the OIDC introspection process respects the configuration flag
that controls whether new users should be automatically provisioned during
authentication, preventing unwanted user proliferation when auto-creation is
explicitly disabled.
"""
settings.OIDC_CREATE_USER = False
with pytest.raises(
User.DoesNotExist,
match="User matching query does not exist.",
):
User.objects.get(sub="very-specific-sub")
assert (
settings.OIDC_RS_BACKEND_CLASS
== "core.external_api.authentication.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet rooms:list",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
@responses.activate
def test_resource_server_authentication_successful(settings):
"""Authenticated requests should be processed and user-specific data is returned.
Verifies that once a user is authenticated via OIDC token introspection,
the API correctly identifies the user and returns only data accessible to that user
(e.g., rooms with appropriate access levels).
"""
user = UserFactory(sub="very-specific-sub")
other_user = UserFactory()
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room_user_accesses = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED, users=[user]
)
RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[other_user])
assert (
settings.OIDC_RS_BACKEND_CLASS
== "core.external_api.authentication.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 1
expected_ids = {
str(room_user_accesses.id),
}
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@responses.activate
def test_resource_server_denies_access_with_insufficient_scopes(settings):
"""Requests should be denied when the token lacks required scopes.
Verifies that the ResourceServerBackend validates token scopes during introspection
and returns 403 Forbidden when the token is missing required scopes for the endpoint.
"""
assert (
settings.OIDC_RS_BACKEND_CLASS
== "core.external_api.authentication.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet", # missing rooms:list scope
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
Binary file not shown.
+167 -74
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,121 +17,153 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
#: core/admin.py:29
msgid "Personal info"
msgstr "Persönliche Informationen"
#: core/admin.py:39
#: core/admin.py:42
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:51
#: core/admin.py:54
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:147
#: core/admin.py:128 core/admin.py:228
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:150
#: core/admin.py:131 core/admin.py:231
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
#: core/api/serializers.py:67
#: core/admin.py:143
msgid "Resend notification to external service"
msgstr "Benachrichtigung erneut an externen Dienst senden"
#: core/admin.py:166
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
#: core/admin.py:174
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
#: core/admin.py:182
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
#: core/admin.py:190
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:294
msgid "No scopes"
msgstr "Keine Scopes"
#: core/admin.py:296
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/models.py:31
#: core/models.py:34
msgid "Member"
msgstr "Mitglied"
#: core/models.py:32
#: core/models.py:35
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:33
#: core/models.py:36
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:49
#: core/models.py:52
msgid "Initiated"
msgstr "Gestartet"
#: core/models.py:50
#: core/models.py:53
msgid "Active"
msgstr "Aktiv"
#: core/models.py:51
#: core/models.py:54
msgid "Stopped"
msgstr "Beendet"
#: core/models.py:52
#: core/models.py:55
msgid "Saved"
msgstr "Gespeichert"
#: core/models.py:53
#: core/models.py:56
msgid "Aborted"
msgstr "Abgebrochen"
#: core/models.py:54
#: core/models.py:57
msgid "Failed to Start"
msgstr "Start fehlgeschlagen"
#: core/models.py:55
#: core/models.py:58
msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen"
#: core/models.py:56
#: core/models.py:59
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:83
#: core/models.py:86
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:84
#: core/models.py:87
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:90
#: core/models.py:93
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:91
#: core/models.py:94
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:92
#: core/models.py:95
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:104
#: core/models.py:107
msgid "id"
msgstr "ID"
#: core/models.py:105
#: core/models.py:108
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:111
#: core/models.py:114
msgid "created on"
msgstr "erstellt am"
#: core/models.py:112
#: core/models.py:115
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:117
#: core/models.py:120
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:118
#: core/models.py:121
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:138
#: core/models.py:141
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -139,11 +171,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:144
#: core/models.py:147
msgid "sub"
msgstr "Sub"
#: core/models.py:146
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -151,55 +183,55 @@ msgstr ""
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
"erlaubt."
#: core/models.py:154
#: core/models.py:157
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:159
#: core/models.py:162
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:161
#: core/models.py:164
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:163
#: core/models.py:166
msgid "short name"
msgstr "Kurzname"
#: core/models.py:169
#: core/models.py:172
msgid "language"
msgstr "Sprache"
#: core/models.py:170
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:176
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:179
#: core/models.py:182
msgid "device"
msgstr "Gerät"
#: core/models.py:181
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:184
#: core/models.py:187
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:186
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:189
#: core/models.py:192
msgid "active"
msgstr "aktiv"
#: core/models.py:192
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -207,66 +239,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:205
#: core/models.py:208
msgid "user"
msgstr "Benutzer"
#: core/models.py:206
#: core/models.py:209
msgid "users"
msgstr "Benutzer"
#: core/models.py:265
#: core/models.py:268
msgid "Resource"
msgstr "Ressource"
#: core/models.py:266
#: core/models.py:269
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:320
#: core/models.py:323
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:321
#: core/models.py:324
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:327
#: core/models.py:330
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:383
#: core/models.py:386
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:384
#: core/models.py:387
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:391
#: core/models.py:394
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:392
#: core/models.py:395
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:398 core/models.py:552
#: core/models.py:401 core/models.py:555
msgid "Room"
msgstr "Raum"
#: core/models.py:399
#: core/models.py:402
msgid "Rooms"
msgstr "Räume"
#: core/models.py:563
#: core/models.py:566
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:565
#: core/models.py:568
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -275,42 +307,102 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:573
#: core/models.py:576
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:574
#: core/models.py:577
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:580
#: core/models.py:583
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:581
#: core/models.py:584
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:689
#: core/models.py:692
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:690
#: core/models.py:693
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:696
#: core/models.py:699
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:702
#: core/models.py:705
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:708
#: core/models.py:711
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:728
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:729
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:731
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:732
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:745
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:767
msgid "Application"
msgstr "Anwendung"
#: core/models.py:768
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:794
msgid "Domain"
msgstr "Domain"
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:807
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:808
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -401,7 +493,8 @@ msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen. "
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur "
"Organisatoren können sie herunterladen. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
@@ -438,18 +531,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:163
#: meet/settings.py:167
msgid "English"
msgstr "Englisch"
#: meet/settings.py:164
#: meet/settings.py:168
msgid "French"
msgstr "Französisch"
#: meet/settings.py:165
#: meet/settings.py:169
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:166
#: meet/settings.py:170
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+170 -74
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,119 +17,151 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
#: core/admin.py:29
msgid "Personal info"
msgstr ""
msgstr "Personal info"
#: core/admin.py:39
#: core/admin.py:42
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:51
#: core/admin.py:54
msgid "Important dates"
msgstr "Important dates"
#: core/admin.py:147
#: core/admin.py:128 core/admin.py:228
msgid "No owner"
msgstr "No owner"
#: core/admin.py:150
#: core/admin.py:131 core/admin.py:231
msgid "Multiple owners"
msgstr "Multiple owners"
#: core/api/serializers.py:67
#: core/admin.py:143
msgid "Resend notification to external service"
msgstr "Resend notification to external service"
#: core/admin.py:166
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Failed to notify for recording %(id)s"
#: core/admin.py:174
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Failed to notify for recording %(id)s: %(error)s"
#: core/admin.py:182
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Successfully sent notifications for %(count)s recording(s)."
#: core/admin.py:190
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:294
msgid "No scopes"
msgstr "No scopes"
#: core/admin.py:296
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/models.py:31
#: core/models.py:34
msgid "Member"
msgstr "Member"
#: core/models.py:32
#: core/models.py:35
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:33
#: core/models.py:36
msgid "Owner"
msgstr "Owner"
#: core/models.py:49
#: core/models.py:52
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:50
#: core/models.py:53
msgid "Active"
msgstr "Active"
#: core/models.py:51
#: core/models.py:54
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:52
#: core/models.py:55
msgid "Saved"
msgstr "Saved"
#: core/models.py:53
#: core/models.py:56
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:54
#: core/models.py:57
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:55
#: core/models.py:58
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:56
#: core/models.py:59
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:83
#: core/models.py:86
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:84
#: core/models.py:87
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:90
#: core/models.py:93
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:91
#: core/models.py:94
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:92
#: core/models.py:95
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:104
#: core/models.py:107
msgid "id"
msgstr "id"
#: core/models.py:105
#: core/models.py:108
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:111
#: core/models.py:114
msgid "created on"
msgstr "created on"
#: core/models.py:112
#: core/models.py:115
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:117
#: core/models.py:120
msgid "updated on"
msgstr "updated on"
#: core/models.py:118
#: core/models.py:121
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:138
#: core/models.py:141
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -137,11 +169,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:144
#: core/models.py:147
msgid "sub"
msgstr "sub"
#: core/models.py:146
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -149,55 +181,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:154
#: core/models.py:157
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:159
#: core/models.py:162
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:161
#: core/models.py:164
msgid "full name"
msgstr "full name"
#: core/models.py:163
#: core/models.py:166
msgid "short name"
msgstr "short name"
#: core/models.py:169
#: core/models.py:172
msgid "language"
msgstr "language"
#: core/models.py:170
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:176
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:179
#: core/models.py:182
msgid "device"
msgstr "device"
#: core/models.py:181
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:184
#: core/models.py:187
msgid "staff status"
msgstr "staff status"
#: core/models.py:186
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:189
#: core/models.py:192
msgid "active"
msgstr "active"
#: core/models.py:192
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -205,63 +237,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:205
#: core/models.py:208
msgid "user"
msgstr "user"
#: core/models.py:206
#: core/models.py:209
msgid "users"
msgstr "users"
#: core/models.py:265
#: core/models.py:268
msgid "Resource"
msgstr "Resource"
#: core/models.py:266
#: core/models.py:269
msgid "Resources"
msgstr "Resources"
#: core/models.py:320
#: core/models.py:323
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:321
#: core/models.py:324
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:327
#: core/models.py:330
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:383
#: core/models.py:386
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:384
#: core/models.py:387
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:391
#: core/models.py:394
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:392
#: core/models.py:395
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:398 core/models.py:552
#: core/models.py:401 core/models.py:555
msgid "Room"
msgstr "Room"
#: core/models.py:399
#: core/models.py:402
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:563
#: core/models.py:566
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:565
#: core/models.py:568
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -269,42 +301,106 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:573
#: core/models.py:576
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:574
#: core/models.py:577
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:580
#: core/models.py:583
msgid "Recording"
msgstr "Recording"
#: core/models.py:581
#: core/models.py:584
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:689
#: core/models.py:692
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:690
#: core/models.py:693
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:696
#: core/models.py:699
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:702
#: core/models.py:705
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:708
#: core/models.py:711
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:728
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:729
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:731
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:732
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:745
msgid "Application name"
msgstr "Application name"
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:767
msgid "Application"
msgstr "Application"
#: core/models.py:768
msgid "Applications"
msgstr "Applications"
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:794
msgid "Domain"
msgstr "Domain"
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:807
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:808
msgid "Application domains"
msgstr "Application domains"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -433,18 +529,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:163
#: meet/settings.py:167
msgid "English"
msgstr "English"
#: meet/settings.py:164
#: meet/settings.py:168
msgid "French"
msgstr "French"
#: meet/settings.py:165
#: meet/settings.py:169
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:166
#: meet/settings.py:170
msgid "German"
msgstr "German"
Binary file not shown.
+167 -74
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,123 +17,155 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
#: core/admin.py:29
msgid "Personal info"
msgstr "Informations personnelles"
#: core/admin.py:39
#: core/admin.py:42
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:51
#: core/admin.py:54
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:147
#: core/admin.py:128 core/admin.py:228
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:150
#: core/admin.py:131 core/admin.py:231
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
#: core/api/serializers.py:67
#: core/admin.py:143
msgid "Resend notification to external service"
msgstr "Renvoyer la notification au service externe"
#: core/admin.py:166
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Échec de la notification pour lenregistrement %(id)s"
#: core/admin.py:174
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Échec de la notification pour lenregistrement %(id)s : %(error)s"
#: core/admin.py:182
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
#: core/admin.py:190
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
#: core/admin.py:294
msgid "No scopes"
msgstr "Aucun scopes"
#: core/admin.py:296
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/models.py:31
#: core/models.py:34
msgid "Member"
msgstr "Membre"
#: core/models.py:32
#: core/models.py:35
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:33
#: core/models.py:36
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:49
#: core/models.py:52
msgid "Initiated"
msgstr "Initié"
#: core/models.py:50
#: core/models.py:53
msgid "Active"
msgstr "Actif"
#: core/models.py:51
#: core/models.py:54
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:52
#: core/models.py:55
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:53
#: core/models.py:56
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:54
#: core/models.py:57
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:55
#: core/models.py:58
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:56
#: core/models.py:59
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:83
#: core/models.py:86
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:84
#: core/models.py:87
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:90
#: core/models.py:93
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:91
#: core/models.py:94
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:92
#: core/models.py:95
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:104
#: core/models.py:107
msgid "id"
msgstr "id"
#: core/models.py:105
#: core/models.py:108
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:111
#: core/models.py:114
msgid "created on"
msgstr "créé le"
#: core/models.py:112
#: core/models.py:115
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:117
#: core/models.py:120
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:118
#: core/models.py:121
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:138
#: core/models.py:141
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -141,11 +173,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:144
#: core/models.py:147
msgid "sub"
msgstr "sub"
#: core/models.py:146
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -153,55 +185,55 @@ msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
#: core/models.py:154
#: core/models.py:157
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:159
#: core/models.py:162
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:161
#: core/models.py:164
msgid "full name"
msgstr "nom complet"
#: core/models.py:163
#: core/models.py:166
msgid "short name"
msgstr "nom court"
#: core/models.py:169
#: core/models.py:172
msgid "language"
msgstr "langue"
#: core/models.py:170
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:176
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:179
#: core/models.py:182
msgid "device"
msgstr "appareil"
#: core/models.py:181
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:184
#: core/models.py:187
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:186
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:189
#: core/models.py:192
msgid "active"
msgstr "actif"
#: core/models.py:192
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -209,65 +241,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:205
#: core/models.py:208
msgid "user"
msgstr "utilisateur"
#: core/models.py:206
#: core/models.py:209
msgid "users"
msgstr "utilisateurs"
#: core/models.py:265
#: core/models.py:268
msgid "Resource"
msgstr "Ressource"
#: core/models.py:266
#: core/models.py:269
msgid "Resources"
msgstr "Ressources"
#: core/models.py:320
#: core/models.py:323
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:321
#: core/models.py:324
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:327
#: core/models.py:330
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:383
#: core/models.py:386
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:384
#: core/models.py:387
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:391
#: core/models.py:394
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:392
#: core/models.py:395
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:398 core/models.py:552
#: core/models.py:401 core/models.py:555
msgid "Room"
msgstr "Salle"
#: core/models.py:399
#: core/models.py:402
msgid "Rooms"
msgstr "Salles"
#: core/models.py:563
#: core/models.py:566
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:565
#: core/models.py:568
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -275,42 +307,102 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:573
#: core/models.py:576
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:574
#: core/models.py:577
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:580
#: core/models.py:583
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:581
#: core/models.py:584
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:689
#: core/models.py:692
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:690
#: core/models.py:693
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:696
#: core/models.py:699
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:702
#: core/models.py:705
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:708
#: core/models.py:711
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:728
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:729
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:731
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:732
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:745
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun nouveau secret."
#: core/models.py:767
msgid "Application"
msgstr "Application"
#: core/models.py:768
msgid "Applications"
msgstr "Applications"
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:794
msgid "Domain"
msgstr "Domaine"
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:807
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:808
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -401,7 +493,8 @@ msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls les organisateurs peuvent le télécharger."
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls "
"les organisateurs peuvent le télécharger."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
@@ -438,18 +531,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:163
#: meet/settings.py:167
msgid "English"
msgstr "Anglais"
#: meet/settings.py:164
#: meet/settings.py:168
msgid "French"
msgstr "Français"
#: meet/settings.py:165
#: meet/settings.py:169
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:166
#: meet/settings.py:170
msgid "German"
msgstr "Allemand"
Binary file not shown.
+167 -74
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,120 +17,152 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
#: core/admin.py:29
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: core/admin.py:39
#: core/admin.py:42
msgid "Permissions"
msgstr "Rechten"
#: core/admin.py:51
#: core/admin.py:54
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/admin.py:147
#: core/admin.py:128 core/admin.py:228
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:150
#: core/admin.py:131 core/admin.py:231
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
#: core/api/serializers.py:67
#: core/admin.py:143
msgid "Resend notification to external service"
msgstr "Melding opnieuw verzenden naar externe dienst"
#: core/admin.py:166
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Melding voor opname %(id)s mislukt"
#: core/admin.py:174
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
#: core/admin.py:182
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
#: core/admin.py:190
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s verlopen opname(n) overgeslagen."
#: core/admin.py:294
msgid "No scopes"
msgstr "Geen scopes"
#: core/admin.py:296
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/models.py:31
#: core/models.py:34
msgid "Member"
msgstr "Lid"
#: core/models.py:32
#: core/models.py:35
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:33
#: core/models.py:36
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:49
#: core/models.py:52
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:50
#: core/models.py:53
msgid "Active"
msgstr "Actief"
#: core/models.py:51
#: core/models.py:54
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:52
#: core/models.py:55
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:53
#: core/models.py:56
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:54
#: core/models.py:57
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:55
#: core/models.py:58
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:56
#: core/models.py:59
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:83
#: core/models.py:86
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:84
#: core/models.py:87
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:90
#: core/models.py:93
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:91
#: core/models.py:94
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:92
#: core/models.py:95
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:104
#: core/models.py:107
msgid "id"
msgstr "id"
#: core/models.py:105
#: core/models.py:108
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:111
#: core/models.py:114
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:112
#: core/models.py:115
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:117
#: core/models.py:120
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:118
#: core/models.py:121
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:138
#: core/models.py:141
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -138,66 +170,66 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:144
#: core/models.py:147
msgid "sub"
msgstr "sub"
#: core/models.py:146
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
#: core/models.py:154
#: core/models.py:157
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:159
#: core/models.py:162
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:161
#: core/models.py:164
msgid "full name"
msgstr "volledige naam"
#: core/models.py:163
#: core/models.py:166
msgid "short name"
msgstr "korte naam"
#: core/models.py:169
#: core/models.py:172
msgid "language"
msgstr "taal"
#: core/models.py:170
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:176
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:179
#: core/models.py:182
msgid "device"
msgstr "apparaat"
#: core/models.py:181
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:184
#: core/models.py:187
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:186
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:189
#: core/models.py:192
msgid "active"
msgstr "actief"
#: core/models.py:192
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -205,64 +237,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:205
#: core/models.py:208
msgid "user"
msgstr "gebruiker"
#: core/models.py:206
#: core/models.py:209
msgid "users"
msgstr "gebruikers"
#: core/models.py:265
#: core/models.py:268
msgid "Resource"
msgstr "Bron"
#: core/models.py:266
#: core/models.py:269
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:320
#: core/models.py:323
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:321
#: core/models.py:324
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:327
#: core/models.py:330
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:383
#: core/models.py:386
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:384
#: core/models.py:387
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:391
#: core/models.py:394
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:392
#: core/models.py:395
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:398 core/models.py:552
#: core/models.py:401 core/models.py:555
msgid "Room"
msgstr "Ruimte"
#: core/models.py:399
#: core/models.py:402
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:563
#: core/models.py:566
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:565
#: core/models.py:568
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -270,42 +302,102 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:573
#: core/models.py:576
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:574
#: core/models.py:577
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:580
#: core/models.py:583
msgid "Recording"
msgstr "Opname"
#: core/models.py:581
#: core/models.py:584
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:689
#: core/models.py:692
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:690
#: core/models.py:693
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:696
#: core/models.py:699
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:702
#: core/models.py:705
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:708
#: core/models.py:711
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:728
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:729
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:731
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:732
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:745
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:767
msgid "Application"
msgstr "Applicatie"
#: core/models.py:768
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:794
msgid "Domain"
msgstr "Domein"
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:807
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:808
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -396,7 +488,8 @@ msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
"Het delen van de opname via een link is nog niet beschikbaar. Alleen "
"organisatoren kunnen deze downloaden."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
@@ -433,18 +526,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:163
#: meet/settings.py:167
msgid "English"
msgstr "Engels"
#: meet/settings.py:164
#: meet/settings.py:168
msgid "French"
msgstr "Frans"
#: meet/settings.py:165
#: meet/settings.py:169
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:166
#: meet/settings.py:170
msgid "German"
msgstr "Duits"
+54
View File
@@ -10,6 +10,8 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
# pylint: disable=too-many-lines
import json
from os import path
from socket import gethostbyname, gethostname
@@ -326,6 +328,11 @@ class Base(Configuration):
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGIN_ENABLED", environ_prefix=None
),
"idle_disconnect_warning_delay": values.PositiveIntegerValue(
None,
environ_name="FRONTEND_IDLE_DISCONNECT_WARNING_DELAY",
environ_prefix=None,
),
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
@@ -399,6 +406,10 @@ class Base(Configuration):
default=False,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
)
OIDC_TIMEOUT = values.IntegerValue(
5, environ_name="OIDC_TIMEOUT", environ_prefix=None
)
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
OIDC_RP_SIGN_ALGO = values.Value(
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
)
@@ -422,12 +433,16 @@ class Base(Configuration):
OIDC_OP_USER_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
)
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
)
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
)
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
)
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
)
@@ -488,6 +503,45 @@ class Base(Configuration):
environ_prefix=None,
)
# OIDC Resource Server Backend
OIDC_RS_BACKEND_CLASS = "core.external_api.authentication.ResourceServerBackend"
OIDC_RS_CLIENT_ID = values.Value(
"meet", environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
)
OIDC_RS_CLIENT_SECRET = SecretFileValue(
None,
environ_name="OIDC_RS_CLIENT_SECRET",
environ_prefix=None,
)
OIDC_RS_AUDIENCE_CLAIM = values.Value(
default="client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
)
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
default="A256GCM",
environ_name="OIDC_RS_ENCRYPTION_ENCODING",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_ALGO = values.Value(
default="RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None
)
OIDC_RS_SIGNING_ALGO = values.Value(
default="ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
)
OIDC_RS_SCOPES = values.ListValue(
default=["lasuite_meet"],
environ_name="OIDC_RS_SCOPES",
environ_prefix=None,
)
OIDC_RS_PRIVATE_KEY_STR = SecretFileValue(
environ_name="OIDC_RS_PRIVATE_KEY_STR", environ_prefix=None
)
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
default="RSA", environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE", environ_prefix=None
)
OIDC_RS_SCOPES_PREFIX = values.Value(
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
+1
View File
@@ -15,6 +15,7 @@ from drf_spectacular.views import (
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("core.urls")),
path("", include("lasuite.oidc_resource_server.urls")),
]
if settings.DEBUG:
+31 -31
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.40"
version = "1.0.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,38 +25,38 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.38.42",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"boto3==1.40.69",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-lasuite[all]==0.0.10",
"django-cors-headers==4.9.0",
"django-countries==8.0.0",
"django-lasuite[all]==0.0.19",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.7",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
"django==5.2.9",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.24.0",
"markdown==3.8.2",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.9",
"jsonschema==4.25.1",
"markdown==3.10",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.2.12",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
"requests==2.32.4",
"sentry-sdk==2.30.0",
"whitenoise==6.9.0",
"requests==2.32.5",
"sentry-sdk==2.43.0",
"whitenoise==6.11.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.3",
"aiohttp==3.12.13",
"livekit-api==1.0.7",
"aiohttp==3.13.2",
]
[project.urls]
@@ -68,21 +68,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2025.6.1",
"freezegun==1.5.2",
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.3.0",
"pyfakefs==5.9.1",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"pylint==3.3.7",
"pytest-cov==6.2.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==8.4.1",
"pytest==9.0.0",
"pytest-icdiff==0.9",
"pytest-xdist==3.7.0",
"responses==0.25.7",
"ruff==0.12.0",
"types-requests==2.32.4.20250611",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.4",
"types-requests==2.32.4.20250913",
]
[tool.setuptools]
+6 -1
View File
@@ -38,7 +38,12 @@ RUN npm run build
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2 libexpat>=2.7.2-r0
RUN apk update && apk upgrade libssl3 \
libcrypto3 \
libxml2>=2.12.7-r2 \
libxslt>=1.1.39-r2 \
libexpat>=2.7.2-r0 \
libpng>=1.6.53-r0
USER nginx
+39 -30
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.40",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.40",
"version": "1.0.1",
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
@@ -55,7 +55,7 @@
"postcss": "8.5.6",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "7.0.2",
"vite": "7.0.8",
"vite-tsconfig-paths": "5.1.4"
}
},
@@ -104,6 +104,7 @@
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -3818,17 +3819,6 @@
"win32"
]
},
"node_modules/@rrweb/types": {
"version": "2.0.0-alpha.17",
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.17.tgz",
"integrity": "sha512-AfDTVUuCyCaIG0lTSqYtrZqJX39ZEYzs4fYKnexhQ+id+kbZIpIJtaut5cto6dWZbB3SEe4fW0o90Po3LvTmfg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"rrweb-snapshot": "^2.0.0-alpha.17"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz",
@@ -3880,6 +3870,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz",
"integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.81.5"
},
@@ -3971,8 +3962,7 @@
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/dom-mediacapture-transform": {
"version": "0.1.11",
@@ -3988,8 +3978,7 @@
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.15.tgz",
"integrity": "sha512-omOlCPvTWyPm4ZE5bZUhlSvnHM2ZWM2U+1cPiYFL/e8aV5O9MouELp+L4dMKNTON0nTeHqEg+KWDfFQMY5Wkaw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -4016,6 +4005,7 @@
"integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4033,6 +4023,7 @@
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -4099,6 +4090,7 @@
"integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/types": "8.35.1",
@@ -4390,6 +4382,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4804,6 +4797,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.5.4",
@@ -5613,6 +5607,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -6580,6 +6575,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -7464,6 +7460,7 @@
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.7.tgz",
"integrity": "sha512-19m8Q1cvRl5PslRawDUgWXeP8vL8584tX8kiZEJaPZo83U/L6VPS/O7pP06phfJaBWeeV8sAOVtEPlQiZEHtpg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.39.3",
@@ -8123,6 +8120,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8236,6 +8234,7 @@
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -8389,6 +8388,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -8495,6 +8495,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -8744,7 +8745,6 @@
"integrity": "sha512-GBg5pV8LHOTbeVmH2VHLEFR0mc2QpQMzAvcoxEGfPNWgWHc8UvKCyq7pqN1vA+fDZ+yXXbixeO0kB2pzVvFCBw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"postcss": "^8.4.38"
}
@@ -9248,6 +9248,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -9366,7 +9367,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -9478,6 +9480,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9638,6 +9641,7 @@
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.5.tgz",
"integrity": "sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==",
"license": "MIT",
"peer": true,
"dependencies": {
"proxy-compare": "^3.0.1"
},
@@ -9733,15 +9737,16 @@
}
},
"node_modules/vite": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.2.tgz",
"integrity": "sha512-hxdyZDY1CM6SNpKI4w4lcUc3Mtkd9ej4ECWVHSMrOdSinVc2zYOAppHeGc/hzmRo3pxM5blMzkuWHOJA/3NiFw==",
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.8.tgz",
"integrity": "sha512-cJBdq0/u+8rgstg9t7UkBilf8ipLmeXJO30NxD5HAHOivnj10ocV8YtR/XBvd2wQpN3TmcaxNKaHX3tN7o5F5A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
"picomatch": "^4.0.2",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.40.0",
"tinyglobby": "^0.2.14"
@@ -9849,11 +9854,14 @@
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -9864,11 +9872,12 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.40",
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -60,7 +60,7 @@
"postcss": "8.5.6",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "7.0.2",
"vite": "7.0.8",
"vite-tsconfig-paths": "5.1.4"
}
}
+1
View File
@@ -25,6 +25,7 @@ export interface ApiConfig {
custom_css_url?: string
use_french_gov_footer?: boolean
use_proconnect_button?: boolean
idle_disconnect_warning_delay?: number
recording?: {
is_enabled?: boolean
available_modes?: RecordingMode[]
+1
View File
@@ -68,6 +68,7 @@ export const Avatar = ({
{...props}
>
<span
aria-hidden="true"
className={css({
marginTop: '-0.3rem',
})}
@@ -5,6 +5,6 @@ export const authUrl = ({
returnTo = window.location.href,
} = {}) => {
return apiUrl(
`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`
`/authenticate/?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`
)
}
@@ -71,7 +71,11 @@ export const LaterMeetingDialog = ({
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
) : (
<RiFileCopyLine aria-hidden="true" />
)}
</Button>
)}
</div>
@@ -103,13 +107,18 @@ export const LaterMeetingDialog = ({
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
<RiCheckLine
size={18}
style={{ marginRight: '8px' }}
aria-hidden="true"
/>
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
aria-hidden="true"
/>
{t('copy')}
</>
@@ -131,7 +140,11 @@ export const LaterMeetingDialog = ({
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
<RiCheckLine
size={18}
style={{ marginRight: '8px' }}
aria-hidden="true"
/>
{t('copied')}
</>
) : (
@@ -139,6 +152,7 @@ export const LaterMeetingDialog = ({
<RiFileCopyLine
size={18}
style={{ marginRight: '8px', minWidth: '18px' }}
aria-hidden="true"
/>
{isHovered ? (
t('copy')
@@ -173,6 +187,7 @@ export const LaterMeetingDialog = ({
className={css({
fill: 'primary.500',
})}
aria-hidden="true"
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { useRoomContext } from '@livekit/components-react'
import { Spinner } from '@/primitives/Spinner'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Text } from '@/primitives'
import { RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
@@ -31,6 +31,9 @@ export const RecordingStateToast = () => {
const { openTranscript, openScreenRecording } = useSidePanel()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const recordingSnap = useSnapshot(recordingStore)
const hasTranscriptAccess = useHasRecordingAccess(
@@ -128,6 +131,23 @@ export const RecordingStateToast = () => {
}
}, [recordingSnap])
// Update screen reader message only when the key actually changes
// This prevents duplicate announcements caused by re-renders
useEffect(() => {
if (key && key !== lastKeyRef.current) {
lastKeyRef.current = key
const message = t(key)
setSrMessage(message)
// Clear message after 3 seconds to prevent it from being announced again
const timer = setTimeout(() => {
setSrMessage('')
}, 3000)
return () => clearTimeout(timer)
}
}, [key, t])
if (!key)
return isAdminOrOwner ? (
<LimitReachedAlertDialog
@@ -144,67 +164,81 @@ export const RecordingStateToast = () => {
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
return (
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
{isStarted ? (
<RiRecordCircleLine
size={20}
className={css({
animation: 'pulse_background 1s infinite',
})}
/>
) : (
<Spinner size={20} variant="dark" />
)}
<>
{/* Screen reader only message to announce state changes once */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{srMessage}
</div>
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
{isStarted ? (
<RiRecordCircleLine
size={20}
className={css({
animation: 'pulse_background 1s infinite',
})}
/>
) : (
<Spinner size={20} variant="dark" />
)}
{!hasScreenRecordingAccessAndActive &&
!hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
</>
)
}
@@ -9,7 +9,7 @@ import {
useStartRecording,
useStopRecording,
} from '@/features/recording'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
@@ -64,6 +64,17 @@ export const ScreenRecordingSidePanel = () => {
const isRoomConnected = room.state == ConnectionState.Connected
const isRecordingTransitioning = useIsRecordingTransitioning()
// Focus management: focus the primary action button when this side panel opens.
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
requestAnimationFrame(() => {
if (primaryActionRef.current) {
primaryActionRef.current.focus({ preventScroll: true })
}
})
}, [])
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
@@ -153,6 +164,7 @@ export const ScreenRecordingSidePanel = () => {
{t('stop.body')}
</Text>
<Button
ref={primaryActionRef}
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="stop-screen-recording"
@@ -219,6 +231,7 @@ export const ScreenRecordingSidePanel = () => {
)}
</Text>
<Button
ref={primaryActionRef}
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="start-screen-recording"
@@ -11,7 +11,7 @@ import {
useStopRecording,
useHasFeatureWithoutAdminRights,
} from '../index'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
@@ -77,6 +77,17 @@ export const TranscriptSidePanel = () => {
const room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected
// Focus management: focus the primary action button when this side panel opens.
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
if (primaryActionRef.current) {
requestAnimationFrame(() => {
primaryActionRef.current?.focus({ preventScroll: true })
})
}
}, [])
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
@@ -227,6 +238,7 @@ export const TranscriptSidePanel = () => {
{t('stop.body')}
</Text>
<Button
ref={primaryActionRef}
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="stop-transcript"
@@ -296,6 +308,7 @@ export const TranscriptSidePanel = () => {
)}
</Text>
<Button
ref={primaryActionRef}
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="start-transcript"
@@ -30,6 +30,7 @@ import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
export const Conference = ({
roomId,
@@ -169,6 +170,8 @@ export const Conference = ({
kind: null,
})
const isMobile = useIsMobile()
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
@@ -238,7 +241,7 @@ export const Conference = ({
}}
>
<VideoConference />
{showInviteDialog && (
{showInviteDialog && !isMobile && (
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
@@ -67,7 +67,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
gap={0}
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
{t('heading')}
</Heading>
<Div position="absolute" top="5" right="5">
@@ -115,7 +115,11 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
) : (
<RiFileCopyLine aria-hidden="true" />
)}
</Button>
)}
</div>
@@ -147,13 +151,18 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
<RiCheckLine
size={18}
style={{ marginRight: '8px' }}
aria-hidden="true"
/>
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
aria-hidden="true"
/>
{t('copy')}
</>
@@ -71,7 +71,6 @@ export const FullScreenShareWarning = ({
<div
className={css({
position: 'absolute',
zIndex: '1000',
height: '100%',
width: '100%',
})}
@@ -81,7 +80,6 @@ export const FullScreenShareWarning = ({
<div
className={css({
position: 'absolute',
zIndex: '1000',
height: '100%',
width: '100%',
backgroundColor: 'rgba(22, 22, 34, 0.9)',
@@ -115,6 +113,7 @@ export const FullScreenShareWarning = ({
display: 'flex',
flexDirection: 'row',
gap: '1rem',
zIndex: '1000',
})}
>
<Button
@@ -78,12 +78,20 @@ export const Info = () => {
>
{isCopied ? (
<>
<RiCheckLine size={24} style={{ marginRight: '6px' }} />
<RiCheckLine
size={24}
style={{ marginRight: '6px' }}
aria-hidden="true"
/>
{t('roomInformation.button.copied')}
</>
) : (
<>
<RiFileCopyLine size={24} style={{ marginRight: '6px' }} />
<RiFileCopyLine
size={24}
style={{ marginRight: '6px' }}
aria-hidden="true"
/>
{t('roomInformation.button.copy')}
</>
)}
@@ -0,0 +1,102 @@
import { Button, Dialog, H, P } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
export const IsIdleDisconnectModal = () => {
const connectionObserverSnap = useSnapshot(connectionObserverStore)
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
useEffect(() => {
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
setTimeRemaining(IDLE_DISCONNECT_TIMEOUT_MS)
const interval = setInterval(() => {
setTimeRemaining((prev) => {
if (prev <= 1000) {
clearInterval(interval)
connectionObserverStore.isIdleDisconnectModalOpen = false
navigateTo('feedback', { duplicateIdentity: false })
return 0
}
return prev - 1000
})
}, 1000)
return () => clearInterval(interval)
}
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
const minutes = Math.floor(timeRemaining / 1000 / 60)
const seconds = (timeRemaining / 1000) % 60
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
return (
<Dialog
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
role="alertdialog"
type="alert"
aria-label={t('title')}
onClose={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
}}
>
{({ close }) => {
return (
<div>
<div
className={css({
height: '50px',
width: '50px',
backgroundColor: 'blue.100',
borderRadius: '25px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontWeight: '500',
color: 'blue.800',
margin: 'auto',
})}
>
{formattedTime}
</div>
<H lvl={2} centered>
{t('title')}
</H>
<P>
{t('body', {
duration: humanizeDuration(IDLE_DISCONNECT_TIMEOUT_MS, {
language: i18n.language,
}),
})}
</P>
<P>{t('settings')}</P>
<HStack marginTop="2rem">
<Button
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
navigateTo('feedback', { duplicateIdentity: false })
}}
size="sm"
variant="secondary"
>
{t('leaveButton')}
</Button>
<Button onPress={close} size="sm" variant="primary">
{t('stayButton')}
</Button>
</HStack>
</div>
)
}}
</Dialog>
)
}
@@ -0,0 +1,32 @@
import React, { ReactNode } from 'react'
import { css } from '@/styled-system/css'
export interface KeyboardShortcutHintProps {
children: ReactNode
}
/**
* Small reusable bubble used to display and announce keyboard shortcuts,
* typically when an element receives keyboard focus.
*/
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
children,
}) => {
return (
<div
className={css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
paddingInline: '0.5rem',
paddingBlock: '0.1rem',
fontSize: '0.875rem',
})}
>
{children}
</div>
)
}
@@ -35,6 +35,7 @@ export const ParticipantName = ({
style={{
paddingBottom: '0.1rem',
}}
aria-hidden="true"
>
{displayedName}
</Text>
@@ -29,6 +29,9 @@ import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next'
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -102,9 +105,31 @@ export const ParticipantTile: (
})
const isScreenShare = trackReference.source != Track.Source.Camera
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantName = getParticipantName(trackReference.participant)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = {
...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onFocus?.(event)
setHasKeyboardFocus(true)
},
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onBlur?.(event)
const nextTarget = event.relatedTarget as Node | null
if (!event.currentTarget.contains(nextTarget)) {
setHasKeyboardFocus(false)
}
},
}
return (
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
<FullScreenShareWarning trackReference={trackReference} />
@@ -195,10 +220,16 @@ export const ParticipantTile: (
</>
)}
{!disableMetadata && (
<ParticipantTileFocus trackRef={trackReference} />
<ParticipantTileFocus
trackRef={trackReference}
hasKeyboardFocus={hasKeyboardFocus}
/>
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
{hasKeyboardFocus && (
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
)}
</div>
)
})
@@ -131,8 +131,10 @@ const MOUSE_IDLE_TIME = 3000
export const ParticipantTileFocus = ({
trackRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
hasKeyboardFocus: boolean
}) => {
const [hovered, setHovered] = useState(false)
const [opacity, setOpacity] = useState(0)
@@ -140,8 +142,10 @@ export const ParticipantTileFocus = ({
const idleTimerRef = useRef<number | null>(null)
const [isIdleRef, setIsIdleRef] = useState(false)
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
useEffect(() => {
if (hovered && !isIdleRef) {
if (isVisible) {
// Wait for next frame to ensure element is mounted
requestAnimationFrame(() => {
setOpacity(0.6)
@@ -149,7 +153,7 @@ export const ParticipantTileFocus = ({
} else {
setOpacity(0)
}
}, [hovered, isIdleRef])
}, [isVisible])
const handleMouseMove = () => {
if (idleTimerRef.current) {
@@ -180,11 +184,12 @@ export const ParticipantTileFocus = ({
width: '100%',
height: '100%',
})}
aria-hidden={!isVisible}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove}
>
{hovered && (
{isVisible && (
<div
className={css({
backgroundColor: 'primaryDark.50',
@@ -164,7 +164,7 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen} keepAlive={true}>
<Chat />
</Panel>
<Panel isOpen={isToolsOpen}>
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>
@@ -2,7 +2,7 @@ import { A, Div, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { ReactNode, useEffect, useRef } from 'react'
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import {
@@ -114,10 +114,49 @@ const ToolButton = ({
export const Tools = () => {
const { data } = useConfig()
const { openTranscript, openScreenRecording, activeSubPanelId } =
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
const toolsTriggerRef = useRef<HTMLElement | null>(null)
const prevIsToolsOpenRef = useRef(false)
useEffect(() => {
const wasOpen = prevIsToolsOpenRef.current
const isOpen = isToolsOpen
// Tools just opened
if (!wasOpen && isOpen) {
const activeEl = document.activeElement as HTMLElement | null
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "Plus d'options" button that opened the menu
if (activeEl?.tagName === 'DIV') {
toolsTriggerRef.current = document.querySelector<HTMLButtonElement>(
'button[aria-label="Plus d\'options"]'
)
} else {
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
toolsTriggerRef.current = activeEl
}
}
// Tools just closed
if (wasOpen && !isOpen) {
const trigger = toolsTriggerRef.current
if (trigger && document.contains(trigger)) {
requestAnimationFrame(() => {
trigger.focus({ preventScroll: true })
})
}
toolsTriggerRef.current = null
}
prevIsToolsOpenRef.current = isOpen
}, [isToolsOpen])
const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript
)
@@ -93,7 +93,7 @@ export const ToggleDevice = <T extends ToggleSource>({
isDisabled: cannotUseDevice,
})
useLongPress({
keyCode: kind === 'audioinput' ? 'Space' : undefined,
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
onKeyDown,
onKeyUp,
isDisabled: cannotUseDevice,
@@ -1,15 +1,72 @@
import { useRoomContext } from '@livekit/components-react'
import {
useRemoteParticipants,
useRoomContext,
} from '@livekit/components-react'
import { useEffect, useRef } from 'react'
import { DisconnectReason, RoomEvent } from 'livekit-client'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import posthog from 'posthog-js'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { useConfig } from '@/api/useConfig'
import { userPreferencesStore } from '@/stores/userPreferences'
import { useSnapshot } from 'valtio'
export const useConnectionObserver = () => {
const room = useRoomContext()
const connectionStartTimeRef = useRef<number | null>(null)
const { data } = useConfig()
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const userPreferencesSnap = useSnapshot(userPreferencesStore)
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const remoteParticipants = useRemoteParticipants({
updateOnlyOn: [
RoomEvent.ParticipantConnected,
RoomEvent.ParticipantDisconnected,
],
})
useEffect(() => {
// Always clear existing timer on dependency change
if (idleDisconnectModalTimeoutRef.current) {
clearTimeout(idleDisconnectModalTimeoutRef.current)
idleDisconnectModalTimeoutRef.current = null
}
const isEnabled = userPreferencesSnap.is_idle_disconnect_modal_enabled
const delay = data?.idle_disconnect_warning_delay
// Disabled or invalid delay: ensure modal is closed
if (!isEnabled || !delay) {
connectionObserverStore.isIdleDisconnectModalOpen = false
return
}
if (remoteParticipants.length === 0) {
idleDisconnectModalTimeoutRef.current = setTimeout(() => {
connectionObserverStore.isIdleDisconnectModalOpen = true
}, delay)
} else {
connectionObserverStore.isIdleDisconnectModalOpen = false
}
return () => {
if (idleDisconnectModalTimeoutRef.current) {
clearTimeout(idleDisconnectModalTimeoutRef.current)
idleDisconnectModalTimeoutRef.current = null
}
}
}, [
remoteParticipants.length,
data?.idle_disconnect_warning_delay,
userPreferencesSnap.is_idle_disconnect_modal_enabled,
])
useEffect(() => {
if (!isAnalyticsEnabled) return
@@ -1,5 +1,5 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import * as React from 'react'
import React, { useEffect } from 'react'
import {
formatChatMessageLinks,
useChat,
@@ -36,6 +36,36 @@ export function Chat({ ...props }: ChatProps) {
const { isChatOpen } = useSidePanel()
const chatSnap = useSnapshot(chatStore)
// Keep track of the element that opened the chat so we can restore focus
// when the chat panel is closed.
const prevIsChatOpenRef = React.useRef(false)
const chatTriggerRef = React.useRef<HTMLElement | null>(null)
useEffect(() => {
const wasChatOpen = prevIsChatOpenRef.current
const isChatPanelOpen = isChatOpen
// Chat just opened
if (!wasChatOpen && isChatPanelOpen) {
chatTriggerRef.current = document.activeElement as HTMLElement | null
// Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true })
})
}
// Chat just closed
if (wasChatOpen && !isChatPanelOpen) {
const trigger = chatTriggerRef.current
if (trigger && document.contains(trigger)) {
trigger.focus({ preventScroll: true })
}
chatTriggerRef.current = null
}
prevIsChatOpenRef.current = isChatPanelOpen
}, [isChatOpen])
// Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants()
@@ -45,7 +75,7 @@ export function Chat({ ...props }: ChatProps) {
async function handleSubmit(text: string) {
if (!send || !text) return
await send(text)
inputRef?.current?.focus()
inputRef?.current?.focus({ preventScroll: true })
}
// TEMPORARY: This is a brittle workaround that relies on message count tracking
@@ -11,6 +11,7 @@ import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
@@ -19,6 +20,18 @@ export function DesktopControlBar({
}: Readonly<ControlBarAuxProps>) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
useRegisterKeyboardShortcut({
shortcut: { key: 'F2' },
handler: () => {
const root = desktopControlBarEl.current
if (!root) return
const firstButton = root.querySelector<HTMLButtonElement>(
'button, [role="button"], [tabindex="0"]'
)
firstButton?.focus()
},
})
return (
<div
ref={desktopControlBarEl}
@@ -36,6 +36,7 @@ import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
import { Subtitles } from '@/features/subtitle/component/Subtitles'
import { CarouselLayout } from '../components/layout/CarouselLayout'
import { GridLayout } from '../components/layout/GridLayout'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
const LayoutWrapper = styled(
'div',
@@ -191,6 +192,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<div
// todo - extract these magic values into constant
style={{
@@ -2,6 +2,8 @@ import { Field, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { userPreferencesStore } from '@/stores/userPreferences'
import { useSnapshot } from 'valtio'
export type GeneralTabProps = Pick<TabPanelProps, 'id'>
@@ -9,6 +11,8 @@ export const GeneralTab = ({ id }: GeneralTabProps) => {
const { t, i18n } = useTranslation('settings')
const { languagesList, currentLanguage } = useLanguageLabels()
const userPreferencesSnap = useSnapshot(userPreferencesStore)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('language.heading')}</H>
@@ -21,6 +25,20 @@ export const GeneralTab = ({ id }: GeneralTabProps) => {
i18n.changeLanguage(lang as string)
}}
/>
<H lvl={2}>{t('preferences.title')}</H>
<Field
type="switch"
label={t('preferences.idleDisconnectModal.label')}
description={t('preferences.idleDisconnectModal.description')}
isSelected={userPreferencesSnap.is_idle_disconnect_modal_enabled}
onChange={(value) =>
(userPreferencesStore.is_idle_disconnect_modal_enabled = value)
}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</TabPanel>
)
}
@@ -1,5 +1,5 @@
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { H, Switch } from '@/primitives'
import { Field, H } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
@@ -23,15 +23,19 @@ export const NotificationsTab = ({ id }: NotificationsTabProps) => {
{Array.from(notificationsSnap.soundNotifications).map(
([key, value]) => (
<li key={key}>
<Switch
aria-label={`${t(`actions.${value ? 'disable' : 'enable'}`)} ${t('label')} "${t(`items.${key}`)}"`}
<Field
type="switch"
aria-label={`${t(`actions.${value ? 'disable' : 'enable'}`)} ${t('label')} "${t(`items.${key}.label`)}"`}
label={t(`items.${key}.label`)}
isSelected={value}
onChange={(v) => {
notificationsStore.soundNotifications.set(key, v)
}}
>
{t(`items.${key}`)}
</Switch>
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</li>
)
)}
+1 -1
View File
@@ -7,7 +7,7 @@
},
"feedback": {
"context": "Produkt in Entwicklung — Ihr Feedback ist wichtig!",
"cta": "Teilen Sie uns Ihre Meinung mit"
"cta": "Teilen Sie uns Ihre Meinung mit - neues Fenster"
},
"forbidden": {
"heading": "Zugriff verweigert"
+9
View File
@@ -149,6 +149,13 @@
"newTab": "Neues Fenster"
}
},
"isIdleDisconnectModal": {
"title": "Bist du noch da?",
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
"settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.",
"stayButton": "Gespräch fortsetzen",
"leaveButton": "Jetzt verlassen"
},
"controls": {
"microphone": "Mikrofon",
"camera": "Kamera",
@@ -505,6 +512,8 @@
}
},
"participantTileFocus": {
"containerLabel": "Optionen für {{name}}",
"toolbarHint": "F2: zur Symbolleiste unten.",
"pin": {
"enable": "Anheften",
"disable": "Lösen"
+16 -3
View File
@@ -7,6 +7,13 @@
"authentication": "Authentifizierung",
"nameError": "Ihr Name darf nicht leer sein"
},
"preferences": {
"title": "Einstellungen",
"idleDisconnectModal": {
"label": "Anrufe ohne Teilnehmer verlassen",
"description": "Verlässt automatisch einen Anruf nach einigen Minuten, wenn kein anderer Teilnehmer beitritt"
}
},
"audio": {
"microphone": {
"heading": "Mikrofon",
@@ -69,9 +76,15 @@
"enable": "Aktivieren"
},
"items": {
"participantJoined": "Teilnehmer beigetreten",
"handRaised": "Hand gehoben",
"messageReceived": "Nachricht erhalten"
"participantJoined": {
"label": "Teilnehmer beigetreten"
},
"handRaised": {
"label": "Hand gehoben"
},
"messageReceived": {
"label": "Nachricht erhalten"
}
}
},
"dialog": {
+1 -1
View File
@@ -7,7 +7,7 @@
},
"feedback": {
"context": "Product under development — your input matters!",
"cta": "Share your feedback"
"cta": "Share your feedback - new tab"
},
"forbidden": {
"heading": "You don't have the permission to view this page"
+9
View File
@@ -149,6 +149,13 @@
"newTab": "New window"
}
},
"isIdleDisconnectModal": {
"title": "Are you still there?",
"body": "You are the only participant. This call will end in {{duration}}. Would you like to continue the call?",
"settings": "To stop seeing this message, go to Settings > General.",
"stayButton": "Continue the call",
"leaveButton": "Leave now"
},
"controls": {
"microphone": "Microphone",
"camera": "Camera",
@@ -505,6 +512,8 @@
}
},
"participantTileFocus": {
"containerLabel": "Options for {{name}}",
"toolbarHint": "F2: go to the bottom toolbar.",
"pin": {
"enable": "Pin",
"disable": "Unpin"
+16 -3
View File
@@ -7,6 +7,13 @@
"authentication": "Authentication",
"nameError": "Your name cannot be empty"
},
"preferences": {
"title": "Preferences",
"idleDisconnectModal": {
"label": "Leave calls with no participants",
"description": "Automatically leaves a call after a few minutes if no other participant joins"
}
},
"audio": {
"microphone": {
"heading": "Microphone",
@@ -69,9 +76,15 @@
"enable": "Enable"
},
"items": {
"participantJoined": "Participant joined",
"handRaised": "Hand raised",
"messageReceived": "Message received"
"participantJoined": {
"label": "Participant joined"
},
"handRaised": {
"label": "Hand raised"
},
"messageReceived": {
"label": "Message received"
}
}
},
"dialog": {
+1 -1
View File
@@ -7,7 +7,7 @@
},
"feedback": {
"context": "Produit en cours de développement — votre avis compte !",
"cta": "Partagez votre avis"
"cta": "Partagez votre avis - nouvelle fenêtre"
},
"forbidden": {
"heading": "Accès interdit"
+9
View File
@@ -149,6 +149,13 @@
"newTab": "Nouvelle fenêtre"
}
},
"isIdleDisconnectModal": {
"title": "Êtes-vous toujours là ?",
"body": "Vous êtes le seul participant. Cet appel va donc se terminer dans {{duration}}. Voulez-vous poursuivre l'appel ?",
"settings": "Pour ne plus voir ce message, accèder à Paramètres > Général.",
"stayButton": "Poursuivre l'appel",
"leaveButton": "Quitter maintenant"
},
"controls": {
"microphone": "Microphone",
"camera": "Camera",
@@ -505,6 +512,8 @@
}
},
"participantTileFocus": {
"containerLabel": "Options pour {{name}}",
"toolbarHint": "F2 : raccourci barre d'outils en bas.",
"pin": {
"enable": "Épingler",
"disable": "Annuler l'épinglage"
+16 -3
View File
@@ -7,6 +7,13 @@
"authentication": "Authentification",
"nameError": "Votre Nom ne peut pas être vide"
},
"preferences": {
"title": "Préférences",
"idleDisconnectModal": {
"label": "Quitter les appels sans autre participant",
"description": "Vous fait quitter un appel au bout de quelques minutes si aucun autre participant ne vous rejoint"
}
},
"audio": {
"microphone": {
"heading": "Micro",
@@ -69,9 +76,15 @@
"enable": "Activer"
},
"items": {
"participantJoined": "Un nouveau participant",
"handRaised": "Une main levée",
"messageReceived": "Un message reçu"
"participantJoined": {
"label": "Un nouveau participant"
},
"handRaised": {
"label": "Une main levée"
},
"messageReceived": {
"label": "Un message reçu"
}
}
},
"dialog": {
+1 -1
View File
@@ -7,7 +7,7 @@
},
"feedback": {
"context": "Product in ontwikkeling - uw input is belangrijk!",
"cta": "Deel uw feedback"
"cta": "Deel uw feedback - nieuw tabblad"
},
"forbidden": {
"heading": "U hebt geen toestemming om deze pagina te bekijken"
+9
View File
@@ -149,6 +149,13 @@
"newTab": "Nieuw venster"
}
},
"isIdleDisconnectModal": {
"title": "Ben je er nog?",
"body": "Je bent de enige deelnemer. Dit gesprek wordt over {{duration}} beëindigd. Wil je het gesprek voortzetten?",
"settings": "Om dit bericht niet meer te zien, ga naar Instellingen > Algemeen.",
"stayButton": "Gesprek voortzetten",
"leaveButton": "Nu verlaten"
},
"controls": {
"microphone": "Microfoon",
"camera": "Camera",
@@ -505,6 +512,8 @@
}
},
"participantTileFocus": {
"containerLabel": "Opties voor {{name}}",
"toolbarHint": "F2: naar de werkbalk onderaan.",
"pin": {
"enable": "Pinnen",
"disable": "Losmaken"
+16 -3
View File
@@ -7,6 +7,13 @@
"authentication": "Authenticatie",
"nameError": "Uw naam mag niet leeg zijn"
},
"preferences": {
"title": "Voorkeuren",
"idleDisconnectModal": {
"label": "Verlaat oproepen zonder deelnemers",
"description": "Verlaat automatisch een oproep na een paar minuten als geen andere deelnemer meedoet"
}
},
"audio": {
"microphone": {
"heading": "Microfoon",
@@ -69,9 +76,15 @@
"enable": "Inschakelen"
},
"items": {
"participantJoined": "Deelnemer is toegevoegd",
"handRaised": "Hand opgestoken",
"messageReceived": "Bericht ontvangen"
"participantJoined": {
"label": "Deelnemer is toegevoegd"
},
"handRaised": {
"label": "Hand opgestoken"
},
"messageReceived": {
"label": "Bericht ontvangen"
}
}
},
"dialog": {
+4
View File
@@ -28,6 +28,10 @@ const box = cva({
width: '30rem',
maxWidth: '100%',
},
alert: {
width: '24rem',
maxWidth: '100%',
},
},
variant: {
light: {
+21 -22
View File
@@ -5,7 +5,7 @@ import {
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
import { ReactNode, forwardRef } from 'react'
import { Loader } from './Loader'
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
@@ -17,25 +17,24 @@ export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
icon?: ReactNode
}
export const Button = ({
tooltip,
tooltipType = 'instant',
...props
}: ButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ tooltip, tooltipType = 'instant', ...props }, ref) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
className={[buttonRecipe(variantProps), className].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
{props.loading && <Loader />}
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
</TooltipWrapper>
)
}
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
ref={ref}
className={[buttonRecipe(variantProps), className].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
{props.loading && <Loader />}
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
</TooltipWrapper>
)
}
)
+7 -2
View File
@@ -80,7 +80,7 @@ export type DialogProps = RACDialogProps & {
* after user interaction
*/
onOpenChange?: (isOpen: boolean) => void
type?: 'flex'
type?: 'flex' | 'alert'
innerRef?: MutableRefObject<HTMLDivElement | null>
size?: 'full' | 'large'
}
@@ -96,7 +96,12 @@ export const Dialog = ({
...dialogProps
}: DialogProps) => {
const isAlert = dialogProps['role'] === 'alertdialog'
const boxType = dialogProps['type'] !== 'flex' ? 'dialog' : undefined
const boxType =
dialogProps['type'] === 'alert'
? 'alert'
: dialogProps['type'] !== 'flex'
? 'dialog'
: undefined
return (
<StyledModalOverlay
isKeyboardDismissDisabled={isAlert}
+3 -1
View File
@@ -259,6 +259,7 @@ export const Field = <T extends object>({
}
if (type === 'switch') {
const SWITCH_COMPONENT_WIDTH = '41px'
return (
<FieldWrapper {...props.wrapperProps}>
<div
@@ -273,10 +274,11 @@ export const Field = <T extends object>({
{description && (
<Text
variant="note"
wrap={'pretty'}
wrap={'balance'}
className={css({
textStyle: 'sm',
marginBottom: '0.5rem',
marginRight: SWITCH_COMPONENT_WIDTH,
})}
>
{description}
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
isIdleDisconnectModalOpen: boolean
}
export const connectionObserverStore = proxy<State>({
isIdleDisconnectModalOpen: false,
})
@@ -0,0 +1,38 @@
import { proxy } from 'valtio'
import { subscribe } from 'valtio/index'
import { STORAGE_KEYS } from '@/utils/storageKeys'
type State = {
is_idle_disconnect_modal_enabled: boolean
}
const DEFAULT_STATE = {
is_idle_disconnect_modal_enabled: true,
}
function getUserPreferencesState(): State {
try {
const stored = localStorage.getItem(STORAGE_KEYS.USER_PREFERENCES)
if (!stored) return DEFAULT_STATE
const parsed = JSON.parse(stored)
return {
...DEFAULT_STATE,
...parsed,
}
} catch (error: unknown) {
console.error(
'[UserPreferencesStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
export const userPreferencesStore = proxy<State>(getUserPreferencesState())
subscribe(userPreferencesStore, () => {
localStorage.setItem(
STORAGE_KEYS.USER_PREFERENCES,
JSON.stringify(userPreferencesStore)
)
})

Some files were not shown because too many files have changed in this diff Show More