Compare commits

..

117 Commits

Author SHA1 Message Date
Sylvain Zimmer ca98cf5fac (entitlements) add Entitlements system with pluggable backends
This follows implementations in Drive, Messages and Calendars. This
system allows Meet to gate some features for users depending on an
authorization server. We provide 2 backends: a local one that always
allows room creation, mimicking the current behaviour, and a DeployCenter
backend, that fetches a "can_create" flag from a remote API. Future
deployment contexts might add new backends, or reuse the API format
of the DeployCenter one.
2026-03-07 14:07:37 +01:00
lebaudantoine e56c0f997e 🩹(frontend) fix overflow in participant metadata layout
Recent styling changes introduced an overflow, causing the network
indicator to be pushed outside of the participant tile.

Remove width: 100% and add a minimal gap to prevent metadata
elements from being too close to each other.
2026-03-04 20:39:09 +01:00
lebaudantoine 61afd94e3a 🩹(frontend) enhance shortcut hint styling using PandaCSS utilities
Refactor styles to leverage PandaCSS inline capabilities for
better clarity and consistency.

Remove an unnecessary div wrapper that was causing a layout shift.
2026-03-04 20:39:09 +01:00
Cyril 3d7aec2b4a ️(frontend) announce selected state to SR in select and menu list
Add visually hidden "selected" text for screen readers.
2026-03-04 19:07:34 +01:00
dependabot[bot] 9c009839f0 Bump minimatch from 3.1.2 to 3.1.5 in /src/frontend
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-04 17:22:29 +01:00
lebaudantoine ec63ddcd47 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit
Create a separate Ingress resource to isolate traffic targeting the
webhook-livekit endpoint and allow applying specific NGINX
annotations to this route.

Use an exact path match to take precedence over the default /api
regex rule defined in the base Ingress.

No similar change is made for the S3 webhook endpoint, as this
dependency will be removed from the project.
2026-03-04 16:30:01 +01:00
lebaudantoine fcde8757e6 🩹(frontend) remove incorrect reference to ProConnect on the prejoin
Remove incorrect reference to ProConnect (DINUM SSO) from content
literals, where it should not be mentioned by default in the
white labeled version.

It closes #1075
2026-03-04 14:04:37 +01:00
Cyril 9610e606eb ️(frontend) prevent focus ring clipping
Change overflow from hidden to visible on invite dialog
2026-03-04 13:39:02 +01:00
Cyril 8362ac0e24 ️(frontend) shortcuts table: semantic structure and kbd badge
caption, th scope, <kbd> for keys, no Tab stops on rows
2026-03-04 12:09:38 +01:00
Cyril f1ddd7fa2f ♻️(frontend) show shortcut hint only on first grid tile via CSS
Use :first-child and :focus-within to restrict hint visibility to the first tile
2026-03-04 12:08:22 +01:00
Cyril 487340efca ♻️(frontend) move fullscreen and recording shortcuts to their components
Register Ctrl+Shift+F in DesktopControlBar, Ctrl+Shift+L in ToolsToggle
2026-03-04 12:07:39 +01:00
Cyril 7ebc928dd3 (frontend) add Ctrl+Shift+/ to open shortcuts settings
Update toolbar hint and register shortcut to open settings on shortcuts tab
2026-03-04 12:07:39 +01:00
Cyril 85de214ca7 💄(frontend) truncate pinned participant name with ellipsis on overflow
Long participant names are now truncated with an ellipsis.
2026-03-04 11:21:10 +01:00
lebaudantoine e3e34dbf31 ️(frontend) optimize countdown check in IsIdleDisconnectModal.tsx
Using Array.includes runs in O(n) on every second of the countdown.

Replace the array with a Set to achieve O(1) lookups for better
performance.
2026-03-04 10:22:29 +01:00
lebaudantoine 555afe4abd ️(frontend) fix roomId RegExp recompilation
The regex was being recreated on every function call, causing
unnecessary performance overhead.

Hoist the RegExp to a module-level constant to reuse the compiled
pattern.
2026-03-04 10:22:29 +01:00
lebaudantoine 78ddb121e3 ️(frontend) avoid recreating inline array props in VideoTab.tsx
The items array was defined inline, creating a new reference on
every render.

Hoist the array to a module-level constant or memoize it with
useMemo to prevent unnecessary re-renders.
2026-03-04 10:22:29 +01:00
lebaudantoine ca9c7fc152 ️(frontend) avoid non-primitive default props recreation on each render
The empty object literal created a new reference every render,
potentially triggering unnecessary re-renders.

Hoist an EMPTY_PROPS constant to the module level and reuse it
instead of allocating a new object.
2026-03-04 10:22:29 +01:00
lebaudantoine 6e3845d0c1 ️(frontend) fix missing import type in Rating.tsx
Replace runtime import of PostHog with a type-only import to
avoid loading the module at runtime.
2026-03-04 10:22:29 +01:00
lebaudantoine 41b171da68 🩹(frontend) fix double await in Join.tsx
Remove redundant await in videoTrack.setDeviceId call
to avoid unnecessary promise chaining.
2026-03-04 10:22:29 +01:00
lebaudantoine 4ad897e756 ️(frontend) optimize enterRoom calls in useWaitingParticipants
Replace sequential await inside the loop with Promise.all, since
each enterRoom call is independent.

This prevents unnecessary delays when multiple participants are
waiting (e.g. 10 participants previously resulted in ~10x longer
execution time).
2026-03-04 10:22:29 +01:00
lebaudantoine 42647d6d25 🦺(backend) strengthen API validation for recording options
Improve validation of parameters accepted when starting a
recording to prevent unsupported or unexpected values.

Language validation will be further tightened to only accept
languages supported by the transcribe microservice.

Add extensive API validation tests to cover these scenarios.
2026-03-03 19:05:15 +01:00
leo 14526808ab ♻️(summary) clean up code and unify logging in preparation for testing
Refactor the summary service to better separate concerns, making components
easier to isolate and test. Unify logging logic to ensure consistent
behavior and reduce duplication across the service layer. These changes
set up the codebase for granular testing.
2026-03-03 15:44:53 +01:00
Florent Chehab 25167495cc 🐛(migrations) use settings in migrations
Use settings directly in migrations to avoid noop
migrations. This might have undisered side effects
if we change the config over time 'invalid' data will be
in the database.

It's a simple quick fix.
Keeping some migrations that are no useless to avoid changing
too much the migration history for users.

Similar to https://github.com/suitenumerique/people/commit/
469014ac415b25be0ceed08b31a87d2d40d743cd
2026-03-03 14:48:06 +01:00
lebaudantoine 720eb6a93e ♻️(backend) extract forbidden permission fields from the serializer
These fields previously triggered a suspicious operation exception
when passed to the API.

Make the list configurable so the serializer behavior can be
adjusted without requiring a new release.
2026-03-03 13:30:10 +01:00
lebaudantoine bfbf253033 🔒️(backend) enhance API input validation to strengthen security
During the bug bounty, attempts were made to pass unexpected hidden
fields to manipulate room behavior and join as a ghost.

Treat these parameters as suspicious. They are not sent by the
frontend, so their presence likely indicates tampering.

Explicitly allow the parameters but emit warning logs to help detect
and investigate suspicious activity.
2026-03-03 13:30:10 +01:00
lebaudantoine 692e0e359e (backend) install pydantic and django-pydantic-field to strengthen API
Super useful for validation when handling unstructured dictionaries.

Follow qbey's recommendation and align with the
suitenumerique/conversation project approach to improve schema
validation and data integrity.
2026-03-03 13:30:10 +01:00
Cyril 1d23cb889a ️(frontend) announce mic/camera state for screen readers on shortcut
announce "Microphone/Camera turned on/off" when toggling via
keyboard shortcut so screen reader users get feedback
2026-03-03 09:46:47 +01:00
lebaudantoine b2ad423886 🔖(minor) bump release to 1.9.0 2026-03-02 14:33:25 +01:00
lebaudantoine 2c7b4bea04 🔒️(ci) disable Trivy scan pending clarification from Aqua Security
The Trivy GitHub repository was wiped over the weekend, raising
suspicions of a potential supply chain attack.

Temporarily disable the scan until the situation is clarified.
2026-03-02 11:29:31 +01:00
lebaudantoine 1eda18ea6e 🔧(ci) introduce Claude security review GitHub Action
Add automated security review on new pull requests to strengthen
early detection of potential vulnerabilities.

Leverage Claude to help identify security issues and highlight
areas requiring special attention.
2026-03-02 11:29:31 +01:00
Cyril 8d5488c333 ️(frontend) add skip link component for keyboard navigation
Improve a11y: skip to main heading, bypass header. RGAA 12.7.
2026-02-27 22:49:03 +01:00
lebaudantoine 5c0e6b6479 ⬆️(frontend) update react-aria-components to a newer version
The previously pinned version (July release) did not support
passing the aria-disabled prop to React Aria Button.

A more recent release (August) introduced this capability.
Upgrade is required to make Cyril's proposal work.
2026-02-27 19:39:55 +01:00
Cyril 077cf59082 ️(frontend) keep carousel nav buttons focusable at first and last slide
use aria-disabled  to prevent focus loss when reaching slide limits
2026-02-27 19:39:55 +01:00
Cyril 4881fa20f5 ️(frontend) fix carousel focus ring visibility with NVDA
add :focus fallback for nav buttons when focus-visible detection fails
2026-02-27 19:39:55 +01:00
Cyril 116db1e697 ️(frontend) improve IntroSlider accessibility for screen readers
add aria-labels with slide position, carousel semantics, live region
2026-02-27 19:39:55 +01:00
Florent Chehab 4b76e9571f ⬆️ (python) bump minimal required python version to 3.13
We are going to use features only available in python 3.13.
We already ship docker images based on python 3.13.

For https://github.com/suitenumerique/meet/pull/1030
2026-02-27 12:37:14 +01:00
Cyril e8739d7e70 ️(frontend) improve JoinMeetingDialog screen reader
Focus input on modal open and improve screen reader announcements
2026-02-26 18:35:15 +01:00
Florent Chehab 602bcf3185 🩹(devex) fix Makefile special character support
Under some shells echo doesn't work as expected with the special formatting.

Using printf when creating the variables make it work and should be more robust.
2026-02-25 18:08:57 +01:00
leo f5e0ddf692 (summary) add localization support for transcription context text
Transcription and summarization results were always generated
using a French text structure (e.g. "Réunion du..."), regardless
of user preference or meeting language. Introduced basic localization
support to adapt generated string languages.
2026-02-25 18:07:19 +01:00
lebaudantoine cd0cec78ba 🩹(frontend) fix German language preference update
German was missing from the frontend/backend language list in the
sync hook, causing user preference updates to be ignored.

Add the language to ensure preference changes are properly applied.
2026-02-25 17:01:02 +01:00
leo e647787170 ♻️(devex) run service as part of make bootstrap
Add run to make bootstrap, thus starting the service. This fixes a
mismatch with development documentation.
2026-02-25 11:15:34 +01:00
lebaudantoine d76b4c9b9f 🔧(dependencies) update default renovate config
Update default Renovate configuration to open PRs on
the first day of each month instead of weekly.

Security updates remain handled immediately by Dependabot, while
Renovate manages regular dependency updates to keep the project
up to date with third-party packages.
2026-02-24 18:51:49 +01:00
lebaudantoine 09c7edecb8 📌(dependencies) pin Django to a version below 6.0.0
Delay upgrading until the ecosystem around Django 6 matures.
Also prevent Renovate from suggesting updates beyond v6.
2026-02-24 18:51:49 +01:00
lebaudantoine f625df6508 ♻️(backend) refactor external API tests
Refactor tests to avoid duplicating JWT secret key configuration.

Introduce configuration of the JWT audience, which previously had no
default value.
2026-02-24 16:07:23 +01:00
lebaudantoine ac87980a27 ♻️(backend) refactor external API authentication classes
Refactor external API authentication classes to inherit from a
common base authentication backend.

Prepare the introduction of a new authentication class responsible
for verifying tokens provided to calendar integrations.

Move token decoding responsibility to the new token service so it
can both generate and validate tokens.

Encapsulate external exceptions and expose a clear interface by
defining custom Python exceptions raised during token validation.

Taken from #897.
2026-02-24 16:07:23 +01:00
lebaudantoine 7cab46dc29 ♻️(backend) encapsulate token generation in a service
Encapsulate token generation logic for authenticating to the
external API in a well-scoped service.

This service can later be reused in other parts of the codebase,
especially for providing tokens required by calendar integrations.

Commit was cherry picked from #897
2026-02-24 16:07:23 +01:00
Cyril 259b739160 ️(a11y) fix focus ring on tab container components
Suppress inherited global focus ring on Tabs, TabList, and TabPanel containers.
2026-02-24 14:37:49 +01:00
lebaudantoine 6f77559633 ⬆️(backend) update python dependencies
Updating ruff led me to refactor an unnecessary lambda
2026-02-24 12:23:22 +01:00
Cyril 2cdf19de77 ♻️(frontend) remove redundant formatLongPressLabel helper
Use i18next interpolation directly in useShortcutFormatting
2026-02-24 09:16:16 +01:00
Cyril fcf08a6dbd 🌐(frontend) localize SR modifier labels
Replace hardcoded 'Alt'/'Shift' in SR formatter with i18next
labels. Use Option/Alt distinction on Mac like Ctrl/Command.
2026-02-24 09:13:03 +01:00
Cyril 7bf623f654 🌐(frontend) localize SR modifier labels
Replace hardcoded 'Alt' and 'Shift' in the SR shortcut
2026-02-24 09:06:53 +01:00
lebaudantoine 1c1d1938d9 🚚(frontend) rename "wellknown" directory to "well-known"
Fix a typo introduced while configuring the correct directory for
automatic container view opening on Windows.
2026-02-23 20:20:54 +01:00
lebaudantoine ddb81765f3 🔧(ci) explicitly set CI permissions to read-only as a precaution
Clarify intent and avoid any ambiguity regarding granted permissions.
2026-02-23 18:00:04 +01:00
Ovgodd 8ca52737cd (frontend) introduce a shortcut settings tab
Work adapted from PR #859 and partially extracted to ship as a
smaller, focused PR.

This allows users to view the full list of available shortcuts.
An editor to customize these shortcuts may be introduced later.
2026-02-23 14:26:52 +01:00
Stephan Meijer 87b9ca2314 👷(docker) add arm64 platform support for image builds
Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-02-23 14:06:54 +01:00
lebaudantoine 8a6419da44 🔨(livekit) pin LiveKit version in the dev stack to match production
Avoid potential synchronization issues caused by version drift.
2026-02-23 12:14:00 +01:00
lebaudantoine 127d4e1d5a ⬆️(frontend) update livekit-related dependencies
Bring packages to the latest compatible versions.
2026-02-23 12:14:00 +01:00
lebaudantoine 99cbc1f784 ⬆️(frontend) update panda-related dependencies
Bring packages to the latest compatible versions.
2026-02-23 12:14:00 +01:00
lebaudantoine 246312c51c ⬆️(frontend) update i18next-related dependencies
Bring packages to the latest compatible versions.
2026-02-23 12:14:00 +01:00
lebaudantoine 1b09683938 ⬆️(frontend) update vite-related dependencies
Bring packages to the latest compatible versions.
2026-02-23 12:14:00 +01:00
lebaudantoine db3d3d61ef ⬆️(frontend) update tanstack-related dependencies
Bring packages to the latest compatible versions.
2026-02-23 12:14:00 +01:00
Cyril c1a797c2c1 💄(frontend) add focus ring to reaction emoji buttons
show outline on keyboard focus, fix when sr is opened
2026-02-23 10:29:17 +01:00
lebaudantoine 4d6a7573c4 ⬆️(mail) update mail-related dependencies
Bring packages to the latest compatible versions.
2026-02-23 10:28:45 +01:00
dependabot[bot] 0b73fd8f06 Bump undici from 6.19.8 to 6.23.0 in /src/frontend
Bumps [undici](https://github.com/nodejs/undici) from 6.19.8 to 6.23.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.19.8...v6.23.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.23.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-23 10:00:40 +01:00
lebaudantoine e362765b21 🔒️(frontend) uninstall curl from the frontend production image
Remove unnecessary package to reduce image size and surface area.
2026-02-20 18:27:17 +01:00
lebaudantoine be79fdac80 🩹(summary) fix pip uninstall order in build stages
Pip was removed before copying the builder stage output, which caused
it to be reinstalled unintentionally. Adjust the order to align with
the backend image behavior.
2026-02-20 18:27:17 +01:00
François Petitit 75a15a0004 Fix typo in buildpack environment variable name 2026-02-20 18:26:24 +01:00
Cyril 3087dfe486 ♻️(frontend) replace custom reactions toolbar with react aria popover
use react aria primitives for escape, focus containment and restore
2026-02-20 18:21:33 +01:00
lebaudantoine 9916ab7d7e 🔖(minor) bump release to 1.8.0 2026-02-20 13:44:19 +01:00
lebaudantoine bd2ad3bb99 📝(changelog) update changelog with recent changes
Update changelog.
2026-02-20 13:17:45 +01:00
lebaudantoine f02fbc85a3 🔒️(agents) upgrade OpenSSL to address CVE-2025-15467
Upgrade OpenSSL and related dependencies to address CVE-2025-15467
in meet-agents.

This vulnerability was blocking the image signature workflow, as it
is classified as a critical dependency.
2026-02-20 13:17:45 +01:00
lebaudantoine 4fd4e074e0 📌(agents) pin protobuf to 6.33.5 to fix CVE-2026-0994
Protobuf is a transitive dependency. Pin it to version 6.33.5 to
address CVE-2026-0994.
2026-02-20 13:17:45 +01:00
lebaudantoine ec3d4f7462 🔒️(agents) uninstall pip from the agents image
Reduce surface area and keep the runtime image minimal.
2026-02-20 13:17:45 +01:00
lebaudantoine 4507325331 🔒️(summary) switch to Alpine base image
Reduce surface area and keep the runtime image minimal.

Alpine 3.22 provides ffmpeg v6 as the latest version.
Alpine 3.23 does not include ffmpeg v7, so upgrade directly to v8.

Install pip temporarily for build steps, then remove it from the
production image.
2026-02-20 13:17:45 +01:00
lebaudantoine dac4a72838 🔒️(backend) uninstall pip in the production image
Reduce surface area and keep the runtime image minimal.
2026-02-20 13:17:45 +01:00
lebaudantoine 5048005fc1 🔧(tilt) use the same user as in production to facilitate testing
Use the same user as in production to facilitate local testing with
the production image.

Assign group 127 to the docker user to mirror CI and match production
practices, even though the rationale for this group mapping is unclear.
2026-02-20 13:17:45 +01:00
lebaudantoine 002c7c0e42 🩹(tilt) fix minor indentation issue in the Tilt file
No functional impact, just a formatting cleanup.
2026-02-20 13:17:45 +01:00
Stephan Meijer e18b732776 ⬆️(ci) upgrade GitHub Actions workflow steps to latest versions
Update all GitHub Actions to their latest major versions for improved
performance, security patches, and Node.js runtime compatibility.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-02-20 11:49:14 +01:00
lebaudantoine ce9f812a7e 🔖(minor) bump release to 1.7.0 2026-02-19 12:37:26 +01:00
lebaudantoine b02591170f 🐛(frontend) configure missing participants shortcut
Configure missing shortcut in the frontend for the participant
side panel.

It was accidentally omitted while merging Cyril's changes.
2026-02-19 12:12:23 +01:00
lebaudantoine e58181f846 🧑‍💻(backend) configure the external application API
Configure the external application API across different Kubernetes setups
to enable seamless usage without repeated configuration
when iterating on endpoints.
2026-02-19 11:16:10 +01:00
lebaudantoine d37f47e82c (frontend) expose Windows app web link
Expose a Windows application web link requested by a partner who wraps Visio
inside a containerized Chrome application due to security concerns and limited
trust in video codecs.

This commit introduces a proof of concept implementation.
We plan to iterate on this approach and likely generalize it under a more
neutral lasuite meet naming in future revisions.
2026-02-19 10:17:06 +01:00
lebaudantoine db80c09c10 ⬆️(frontend) update prettier 2026-02-18 22:10:24 +01:00
lebaudantoine fd9f2a81ca ⬆️(dependencies) update js dependencies 2026-02-18 22:10:24 +01:00
unteem d865db5f1b 📝(doc) fix variable name 2026-02-18 21:45:26 +01:00
unteem 7cc5b2b961 📝(doc) fix env files for docker compose
remove unused env file
mount .env
2026-02-18 21:45:26 +01:00
Cyril c85977cb68 (frontend) add clickable settings general link in idle modal
helps users quickly disable idle warning from the right settings tab.
2026-02-18 15:17:37 +01:00
Ovgodd 3c3b4a32e3 (frontend) support additional shortcuts to broaden accessibility
Add support for additional shortcuts to broaden accessibility and
integration capabilities. Some of these are required to ensure full
functionality with the RENATER SIP media gateway, allowing shortcut
mapping to DTMF signals. Others improve usability for keyboard-only
users; a lightweight helper will be introduced to surface available
shortcuts and make them easier to discover and use.
2026-02-12 18:56:48 +01:00
Ovgodd 9b033c55b2 (frontend) support Shift and Alt key when building shortcuts
Add support for Shift and Alt modifiers when building shortcuts,
expanding the range of possible combinations and allowing more expressive
and flexible shortcut definitions.
2026-02-12 18:56:48 +01:00
Ovgodd a2c7becaf4 ♻️(frontend) centralize shortcuts in a catalog
Centralize shortcuts into a single source of truth, making them easier to
discover and manage, and laying the groundwork for future override support
and the ability to revert to default definitions if needed.

Shortcuts are now retrieved by identifier, while leaving each component
responsible for declaring when a shortcut should be enabled and which
handler should be called;
2026-02-12 18:56:48 +01:00
lebaudantoine 89031abb63 🔖(minor) bump release to 1.6.0 2026-02-10 15:31:29 +01:00
Bastien Ogier fc92fa4eb4 🚀(docs) document Scalingo deployment
(docs) document Scalingo deployment
2026-02-10 10:44:13 +01:00
Bastien Ogier 2c65cc061e 🚀(settings) standardize DATABASE_URL environment retrieval
(settings) standardize DATABASE_URL environment retrieval
2026-02-10 10:44:13 +01:00
Bastien Ogier bfadeae6ee 🚀(scalingo) custom logo override
(scalingo) custom logo override
2026-02-10 10:44:13 +01:00
Sylvain Zimmer 117677bd14 🚀(paas) add PaaS deployment scripts, tested on Scalingo
add PaaS deployment scripts, tested on Scalingo
2026-02-10 10:44:13 +01:00
lebaudantoine 69c6e58017 🔒️(backend) add application validation when consuming external JWT
Token generation already verifies that the application is active, but this
guarantee was not enforced when the token was used. This change adds a
runtime check to ensure the client_id claim matches an existing and active
application when evaluating permissions.

This also introduces an emergency revocation mechanism, allowing all previously
issued tokens for a given application to be invalidated if the application is
disabled.
2026-02-09 22:18:09 +01:00
lebaudantoine 6742f5d19d (backend) monitor throttling rate failure through sentry
Use a mixin, introduced by @lunika in the shared
backend library to monitor throttling behavior.

The mixin tracks when throttling limits are reached, sending errors to Sentry
to trigger alerts when configured. This helps detect misconfigurations,
fine-tune throttling settings, and identify suspicious operations.

This enables safely increasing API throttling limits while ensuring stability,
providing confidence that higher limits won’t break the system.
2026-02-09 15:50:53 +01:00
lebaudantoine 23de7e52bc ♻️(backend) extract throttling classes into a module
Extract throttling classes into a dedicated Python module, following the
structure of suitenumerique/docs.

This is a preparatory refactor to ease upcoming changes to the throttling
implementation. No functional behavior change is introduced in this commit.
2026-02-09 15:50:53 +01:00
lebaudantoine 3887255e9c ♻️(backend) rework permission to better align with DRF responsibilities
If a viewset action is not implemented, the permission layer no longer returns
a 403. Instead, it lets DRF handle the request and return the appropriate 405
Method Not Allowed response, ensuring cleaner and more standard API error
handling.
2026-02-09 12:16:12 +01:00
lebaudantoine 5d6ad3f3f6 🔒️(backend) enhance scope manipulation
Enhance scope manipulation by normalizing and sanitizing
scope values before processing.

Scopes are now converted to lowercase to ensure consistent behavior,
deduplicated while preserving their original order, and handled in a
deterministic way aligned with the intended authorization model.
2026-02-09 12:16:12 +01:00
lebaudantoine 44d68a9c80 (backend) strengthen external API viewset test coverage
Reinforce the test suite around the external API viewset to better
prevent regressions, permission leaks, and unexpected failures.

Adds additional scenarios covering permission enforcement, edge cases,
and error handling to ensure the external API behavior remains stable
and secure as it evolves.
2026-02-09 12:16:12 +01:00
lebaudantoine ed5c1bbd84 ♻️(backend) improve scope prefix removal logic
The previous replace usage was too broad and could remove multiple
occurrences, which was not the original intention.

Replace the replace call with removeprefix, which more accurately
matches the expected behavior by only removing the prefix when present
at the start of the string.
2026-02-09 12:16:12 +01:00
lebaudantoine f8c6da8021 🔐(backend) enforce object-level permission checks on room endpoint
Apply strict permission validation on the external API room endpoint to
enforce the principle of least privilege. Unlike the default API (which allows
unauthenticated room retrieval and filters access in the serializer), the
external API now only exposes rooms to users with explicit permissions.

This change fixes a security issue. Slug-based room retrieval, as supported
by the default API, is not introduced here but could be added later if needed.
Retrieving rooms by UUID is retained, as guessing a UUID is significantly harder
than a slug.

A dedicated permission class was created to avoid coupling permissions between
the default and external APIs. The external API enforces stricter access rules.

Access policies may be revisited based on user and integrator feedback. The
external API currently has no production usage.
2026-02-09 12:16:12 +01:00
lebaudantoine 5ba1657e00 🧪(backend) add test exposing rooms permission flaw in external API
Add a failing test demonstrating that a user can retrieve a room they
do not have access to when the room UUID is known.

This highlights an improper object-level permission verification in the
external API. While exploitation requires obtaining the target room
UUID, this still represents a security issue (BOLA / IDOR class
vulnerability) and must be fixed.

The test documents the expected behavior and will pass once proper
access filtering or permission checks are enforced.
2026-02-09 12:16:12 +01:00
René Fischer c28b8ba902 🌐(frontend) add missing DE translation for accessibility settings 2026-02-08 23:57:51 +01:00
lebaudantoine 6962367e18 🐛(backend) fix notification tests broken by renaming env var
SCREEN_RECORDING_BASE_URL was renamed to RECORDING_DOWNLOAD_BASE_URL.

The new variable supersedes the old one, which is temporarily kept for backward
compatibility. This test failure was missed because the local common file was
out of sync with common.dist.

Add the new variable with a default value of None to ensure a smooth
deprecation path when the old variable is removed.
2026-02-07 00:14:49 +01:00
Cyril 0bd57e8623 💄(frontend) clean up spinner styles
remove inline styles for better maintainability
2026-02-06 23:29:23 +01:00
Cyril 27f2023104 ️(frontend) add reduced-motion spinner fallback
show an hourglass when animations are reduced
2026-02-06 23:29:23 +01:00
lebaudantoine 44362eca23 📝(changelog) update changelog
Update changelog with PR's purpose
2026-02-05 19:16:02 +01:00
lebaudantoine c34a85699b ⬆️(backend) upgrade Django to address multiple high-severity CVEs
This update fixes several SQL injection vulnerabilities, including issues in
RasterField band index handling and crafted column aliases (notably in
QuerySet.order_by()), as reported in CVE-2026-1207, CVE-2026-1287, and
CVE-2026-1312.
2026-02-05 19:16:02 +01:00
lebaudantoine 12d8c4a9db ️(admin) improve recording access select component performance
Replace the basic select component that loaded thousands of options into the
DOM with a smarter component supporting dynamic loading and search.

With large user bases, linking users to recording access caused massive option
lists to render, severely impacting performance. This change dramatically
improves page loading speed.
2026-02-05 19:16:02 +01:00
lebaudantoine 42a05da5c0 🔒️(admin) make recording fields read-only for security and performance
These values should not be updated from the admin interface. Allowing changes
to a recording’s associated room could lead to data leaks (e.g., notifications
being resent to the wrong users after a malicious modification).

Also remove the room select field, which rendered a dropdown with ~150k options,
flooding the DOM and severely degrading page performance.
2026-02-05 19:16:02 +01:00
lebaudantoine 4344dd6e35 ️(admin) optimize room view queries by prefetching user access
Use prefetch_related for the room–user access relationship to avoid N+1
queries. select_related cannot be used here since this is a many-to-many
relation. This significantly improves performance.
2026-02-05 19:16:02 +01:00
lebaudantoine fe28902b2e ️(admin) optimize recording view by selecting room at the SQL level
Use select_related on the room foreign key to avoid N+1 queries. This makes
Django perform a join between tables instead of triggering additional queries
per row, reducing complexity from O(n²) patterns to O(n) and significantly
improving performance.
2026-02-05 19:16:02 +01:00
lebaudantoine 1e1e1a2657 ️(admin) remove list filters based on room in recording view
This was a mistake: the filter was never used in production and caused
performance issues. It generated a list of unique room slugs, bloating the DOM
with thousands of values and slowing down view rendering. Remove this
regression.
2026-02-05 19:16:02 +01:00
lebaudantoine f4e48dafac 📝(frontend) update legal terms
Update legal terms following review and validation by the legal team.
2026-02-05 19:09:12 +01:00
lebaudantoine 9f58efb851 🥅(summary) catch file-related exceptions when handling recording objects
Previously, if a recording file was not found in the bucket, the code would
crash. This adds proper error handling to avoid unhandled failures.
2026-02-05 17:50:35 +01:00
172 changed files with 9359 additions and 4489 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Download Crowdin files
uses: crowdin/github-action@v2
+73 -38
View File
@@ -23,7 +23,13 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -37,18 +43,19 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -59,7 +66,13 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -73,12 +86,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -86,6 +99,7 @@ jobs:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -96,7 +110,13 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -110,12 +130,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -123,6 +143,7 @@ jobs:
context: .
file: ./docker/dinum-frontend/Dockerfile
target: frontend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -133,7 +154,13 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -147,13 +174,13 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/summary/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
@@ -162,6 +189,7 @@ jobs:
context: ./src/summary
file: ./src/summary/Dockerfile
target: production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -172,7 +200,13 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -186,14 +220,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/agents/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
# docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -201,6 +235,7 @@ jobs:
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
+43 -20
View File
@@ -7,14 +7,18 @@ on:
pull_request:
branches:
- "*"
permissions:
contents: read
jobs:
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: show
@@ -39,9 +43,11 @@ jobs:
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -49,9 +55,11 @@ jobs:
lint-changelog:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -62,20 +70,22 @@ jobs:
build-mails:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: "18"
- name: Restore the mail templates
uses: actions/cache@v4
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -95,21 +105,23 @@ jobs:
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
@@ -124,14 +136,16 @@ jobs:
lint-agents:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/agents
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
@@ -144,14 +158,16 @@ jobs:
lint-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
@@ -165,7 +181,8 @@ jobs:
test-back:
runs-on: ubuntu-latest
needs: build-mails
permissions:
contents: read
defaults:
run:
working-directory: src/backend
@@ -216,7 +233,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Create writable /data
run: |
@@ -224,7 +241,7 @@ jobs:
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v4
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -258,7 +275,7 @@ jobs:
mc mb meet/meet-media-storage"
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
@@ -279,9 +296,11 @@ jobs:
lint-front:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install dependencies
run: cd src/frontend/ && npm ci
@@ -294,12 +313,14 @@ jobs:
lint-sdk:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install dependencies
run: npm ci
@@ -312,13 +333,15 @@ jobs:
build-sdk:
runs-on: ubuntu-latest
permissions:
contents: read
needs: lint-sdk
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install dependencies
run: npm ci
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
+29
View File
@@ -0,0 +1,29 @@
# /!\
# Security Note: This action is not hardened against prompt injection attacks and should only be used
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
# to ensure workflows only run after a maintainer has reviewed the PR.
name: Security Review
permissions:
pull-requests: write # Needed for leaving PR comments
contents: read
on:
pull_request:
branches:
- 'main'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 2
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
with:
comment-pr: true
exclude-directories: docs,gitlint,LICENSES,bin
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
+88 -1
View File
@@ -8,9 +8,97 @@ and this project adheres to
## [Unreleased]
### Fixed
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
- ♿(frontend) announce selected state to screen readers #1081
### Changed
- 🔒️(backend) enhance API input validation to strengthen security #1053
- 🦺(backend) strengthen API validation for recording options #1063
- ⚡️(frontend) optimize few performance caveats #1073
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
### Fixed
- 🐛(migrations) use settings in migrations #1058
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
## [1.9.0] - 2026-03-02
### Added
- 👷(docker) add arm64 platform support for image builds
- ✨(summary) add localization support for transcription context text
### Changed
- ♻️(frontend) replace custom reactions toolbar with react aria popover #985
- 🔒️(frontend) uninstall curl from the frontend production image #987
- 💄(frontend) add focus ring to reaction emoji buttons
- ✨(frontend) introduce a shortcut settings tab #975
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
- 🌐(frontend) localize SR modifier labels #1010
- ⬆️(backend) update python dependencies #1011
- ♿️(frontend) fix focus ring on tab container components #1012
- ♿️(frontend) upgrade join meeting modal accessibility #1027
- ⬆️(python) bump minimal required python version to 3.13 #1033
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
- ♿️(frontend) add skip link component for keyboard navigation #1019
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
### Fixed
- 🩹(frontend) fix German language preference update #1021
## [1.8.0] - 2026-02-20
### Changed
- 🔒️(agents) uninstall pip from the agents image
- 🔒️(summary) switch to Alpine base image
- 🔒️(backend) uninstall pip in the production image
### Fixed
- 🔒️(agents) upgrade OpenSSL to address CVE-2025-15467
- 📌(agents) pin protobuf to 6.33.5 to fix CVE-2026-0994
## [1.7.0] - 2026-02-19
### Added
- ✨(frontend) expose Windows app web link #976
- ✨(frontend) support additional shortcuts to broaden accessibility
### Changed
- ✨(frontend) add clickable settings general link in idle modal #974
- ♻️(backend) refactor external API token-related items #1006
## [1.6.0] - 2026-02-10
### Added
- ✨(backend) monitor throttling rate failure through sentry #964
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
### Changed
- ♿️(frontend) improve spinner reducedmotion fallback #931
- ♿️(frontend) fix form labels and autocomplete wiring #932
- 🥅(summary) catch file-related exceptions when handling recording #944
- 📝(frontend) update legal terms #956
- ⚡️(backend) enhance django admin's loading performance #954
- 🌐(frontend) add missing DE translation for accessibility settings
### Fixed
- 🔐(backend) enforce object-level permission checks on room endpoint #959
- 🔒️(backend) add application validation when consuming external JWT #963
## [1.5.0] - 2026-01-28
@@ -56,7 +144,6 @@ and this project adheres to
- ✨(summary) add dutch and german languages
- 🔧(agents) make Silero VAD optional
- 🚸(frontend) explain to a user they were ejected
- ✨(frontend) add keyboard shortcuts display
### Changed
+3
View File
@@ -127,6 +127,9 @@ ARG MEET_STATIC_ROOT=/data/static
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/meet.py /usr/local/etc/gunicorn/meet.py
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
+6 -4
View File
@@ -23,9 +23,10 @@
# ==============================================================================
# VARIABLES
BOLD := \033[1m
RESET := \033[0m
GREEN := \033[1;32m
ESC := $(shell printf '\033')
BOLD := $(ESC)[1m
RESET := $(ESC)[0m
GREEN := $(ESC)[1;32m
# -- Database
@@ -85,7 +86,8 @@ bootstrap: \
demo \
back-i18n-compile \
mails-install \
mails-build
mails-build \
run
.PHONY: bootstrap
# -- Docker/compose
+2
View File
@@ -0,0 +1,2 @@
web: bin/buildpack_start.sh
postdeploy: python manage.py migrate
+5 -1
View File
@@ -18,6 +18,7 @@ docker_build(
'localhost:5001/meet-backend:latest',
context='..',
dockerfile='../Dockerfile',
build_args={'DOCKER_USER': '1001:127'},
only=['./src/backend', './src/mail', './docker'],
target = 'backend-production',
live_update=[
@@ -33,6 +34,7 @@ clean_old_images('localhost:5001/meet-backend')
docker_build(
'localhost:5001/meet-frontend-dinum:latest',
context='..',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../docker/dinum-frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target = 'frontend-production',
@@ -57,6 +59,7 @@ clean_old_images('localhost:5001/meet-frontend-generic')
docker_build(
'localhost:5001/meet-summary:latest',
context='../src/summary',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/summary/Dockerfile',
only=['.'],
target = 'production',
@@ -69,8 +72,9 @@ clean_old_images('localhost:5001/meet-summary')
docker_build(
'localhost:5001/meet-agents:latest',
context='../src/agents',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/agents/Dockerfile',
only=['.'],
only=['.'],
target = 'production',
live_update=[
sync('../src/agents', '/app'),
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-compile script"
# Cleanup
rm -rf docker docs env.d gitlint
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-frontend script"
# Move the frontend build to the nginx root and clean up
mkdir -p build/
mv src/frontend/dist build/frontend-out
ASSETS_DIR=build/frontend-out/assets
if [ -n "$CUSTOM_LOGO_URL" ]; then
# Ensure https
[[ ! "$CUSTOM_LOGO_URL" =~ ^https:// ]] && echo "[custom-logo] ERROR: URL must use HTTPS" >&2 && exit 1
# Prevent SSRF
HOSTNAME=$(echo "$CUSTOM_LOGO_URL" | sed -E 's|^https://([^/:]+).*|\1|')
[[ "$HOSTNAME" =~ ^(localhost|127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|0\.0\.0\.0|\[::1\]) ]] && echo "[custom-logo] ERROR: SSRF blocked: $HOSTNAME" >&2 && exit 1
LOGO_FILE="${ASSETS_DIR}/logo.svg"
TMP_FILE=$(mktemp "${LOGO_FILE}.XXXXXX.tmp")
# Actual download
echo "[custom-logo] INFO: Downloading custom logo from: $CUSTOM_LOGO_URL"
curl -fsSL --tlsv1.2 -o "$TMP_FILE" "$CUSTOM_LOGO_URL"
# Validate filesize
FILESIZE=$(stat -c%s "$TMP_FILE" 2>/dev/null || stat -f%z "$TMP_FILE")
[[ "$FILESIZE" -eq 0 ]] && echo "[custom-logo] ERROR: empty file" >&2 && exit 1
[[ "$FILESIZE" -gt 5242880 ]] && echo "[custom-logo] ERROR: file too large (${FILESIZE}B > 5MB)" >&2 && exit 1
# Validate file type
IS_SVG=false
HEADER=$(head -c 100 "$TMP_FILE" | tr -d '\0' | tr '[:upper:]' '[:lower:]')
[[ "$HEADER" =~ ^.*"<svg".*$ ]] && IS_SVG=true
[[ "$HEADER" =~ ^.*"<?xml".*"<svg".*$ ]] && IS_SVG=true
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
mv -f "$TMP_FILE" "$LOGO_FILE"
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
fi
mv src/backend/* ./
mv deploy/paas/* ./
echo "3.13" > .python-version
echo "." > requirements.txt
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Start the Django backend server
gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
# Start the Nginx server
bin/run &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
# wait for a single child to finish,
wait -n
# then kill all the other tasks
pkill -P $$
+52
View File
@@ -0,0 +1,52 @@
# ERB templated nginx configuration
# see https://doc.scalingo.com/platform/deployment/buildpacks/nginx
upstream backend_server {
server localhost:8000 fail_timeout=0;
}
server {
listen <%= ENV["PORT"] %>;
server_name _;
server_tokens off;
root /app/build/frontend-out;
# Django rest framework
location ^~ /api/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Django admin
location ^~ /admin/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
# Add no-cache headers
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
}
# Optionally, handle 404 errors by redirecting to index.html
error_page 404 =200 /index.html;
}
@@ -1,9 +1,9 @@
upstream meet_backend {
server ${BACKEND_HOST}:8000 fail_timeout=0;
server ${BACKEND_INTERNAL_HOST}:8000 fail_timeout=0;
}
upstream meet_frontend {
server ${FRONTEND_HOST}:8080 fail_timeout=0;
server ${FRONTEND_INTERNAL_HOST}:8080 fail_timeout=0;
}
server {
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.9.0
FROM livekit/livekit-server:v1.9.4
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
+2 -1
View File
@@ -20,8 +20,8 @@ services:
user: ${DOCKER_USER:-1000}
restart: always
env_file:
- .env
- env.d/common
- env.d/backend
- env.d/postgresql
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
@@ -45,6 +45,7 @@ services:
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
env_file:
- .env
- env.d/common
# Uncomment and set your values if using our nginx proxy example
# environment:
+4
View File
@@ -9,6 +9,10 @@ La Suite Meet maintainers use only the Kubernetes deployment method in productio
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.
## Scalingo
La Suite Meet can be deployed on Scalingo PaaS using the Suite Numérique buildpack. See the [Scalingo deployment guide](./scalingo.md) for detailed instructions.
## 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.
+185
View File
@@ -0,0 +1,185 @@
# Deployment on Scalingo
This guide explains how to deploy La Suite Meet on [Scalingo](https://scalingo.com/) using the [Suite Numérique buildpack](https://github.com/suitenumerique/buildpack).
## Overview
Scalingo is a Platform-as-a-Service (PaaS) that simplifies application deployment. This setup uses a custom buildpack to handle both the frontend (Vite) and backend (Django) builds, serving them through Nginx.
## Prerequisites
- A Scalingo account
- Scalingo CLI installed (optional but recommended)
- A PostgreSQL database addon
- A Redis addon (for caching and sessions)
## Step 1: Create Your App
Create a new app on Scalingo using `scalingo` cli or using the [Scalingo dashboard](https://dashboard.scalingo.com/).
## Step 2: Provision Addons
Add the required PostgreSQL and Redis services.
This will set the following environment variables automatically:
- `SCALINGO_POSTGRESQL_URL` - Database connection string
- `SCALINGO_REDIS_URL` - Redis connection string
## Step 3: Configure Environment Variables
Set the following environment variables in your Scalingo app:
### Buildpack Configuration
```bash
scalingo env-set BUILDPACK_URL="https://github.com/suitenumerique/buildpack#main"
scalingo env-set LASUITE_APP_NAME="meet"
scalingo env-set LASUITE_BACKEND_DIR="."
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
scalingo env-set LASUITE_NGINX_DIR="."
scalingo env-set LASUITE_SCRIPT_POSTCOMPILE="bin/buildpack_postcompile.sh"
scalingo env-set LASUITE_SCRIPT_POSTFRONTEND="bin/buildpack_postfrontend.sh"
```
### Database and Cache
```bash
scalingo env-set DATABASE_URL="\$SCALINGO_POSTGRESQL_URL"
scalingo env-set REDIS_URL="\$SCALINGO_REDIS_URL"
```
### Django Settings
```bash
scalingo env-set DJANGO_SETTINGS_MODULE="meet.settings"
scalingo env-set DJANGO_CONFIGURATION="Production"
scalingo env-set DJANGO_SECRET_KEY="<generate-a-secure-secret-key>"
scalingo env-set DJANGO_ALLOWED_HOSTS="my-meet-app.osc-fr1.scalingo.io"
```
### OIDC Authentication
Configure your OIDC provider (e.g., Keycloak, Authentik):
```bash
scalingo env-set OIDC_OP_BASE_URL="https://auth.yourdomain.com/realms/meet"
scalingo env-set OIDC_RP_CLIENT_ID="meet-client-id"
scalingo env-set OIDC_RP_CLIENT_SECRET="<your-client-secret>"
scalingo env-set OIDC_RP_SIGN_ALGO="RS256"
```
### LiveKit Configuration
Meet requires a LiveKit server for video conferencing:
```bash
scalingo env-set LIVEKIT_API_URL="wss://livekit.yourdomain.com"
scalingo env-set LIVEKIT_API_KEY="<your-livekit-api-key>"
scalingo env-set LIVEKIT_API_SECRET="<your-livekit-api-secret>"
```
### Email Configuration (Optional)
For email notifications see https://doc.scalingo.com/platform/app/sending-emails:
```bash
scalingo env-set DJANGO_EMAIL_HOST="smtp.example.org"
scalingo env-set DJANGO_EMAIL_PORT="587"
scalingo env-set DJANGO_EMAIL_HOST_USER="<smtp-user>"
scalingo env-set DJANGO_EMAIL_HOST_PASSWORD="<smtp-password>"
scalingo env-set DJANGO_EMAIL_USE_TLS="True"
scalingo env-set DJANGO_EMAIL_FROM="meet@yourdomain.com"
```
## Step 4: Deploy
Deploy your application:
```bash
git push scalingo main
```
The Procfile will automatically:
1. Build the frontend (Vite)
2. Build the backend (Django)
3. Run the post-compile script (cleanup)
4. Run the post-frontend script (move assets and prepare for deployment)
5. Start Nginx and Gunicorn
6. Run django migrations
## Step 5: Create superuser
After the first deployment, create an admin user:
```bash
scalingo run python manage.py createsuperuser
```
## Custom Domain (Optional)
To use a custom domain:
1. Add the domain in Scalingo dashboard
2. Update `DJANGO_ALLOWED_HOSTS` with your custom domain
3. Configure your DNS to point to Scalingo
```bash
scalingo domains-add meet.yourdomain.com
scalingo env-set DJANGO_ALLOWED_HOSTS="meet.yourdomain.com,my-meet-app.osc-fr1.scalingo.io"
```
## Custom Logo (Optional)
To use a custom logo, set the `CUSTOM_LOGO_URL` environment variable with an HTTPS URL pointing to an SVG item (max 5MB):
```bash
scalingo env-set CUSTOM_LOGO_URL="https://cdn.yourdomain.com/logo.svg"
```
## Troubleshooting
### Check Logs
```bash
scalingo logs --tail
```
### Common Issues
1. **Build fails**: Check that all required environment variables are set
2. **Database connection error**: Verify `DATABASE_URL` is correctly set to `$SCALINGO_POSTGRESQL_URL`
3. **Static files not served**: Ensure the buildpack post-frontend script ran successfully
4. **OIDC errors**: Verify your OIDC provider configuration and callback URLs
### Useful Commands
```bash
# Open a console
scalingo run bash
# Restart the app
scalingo restart
# Scale containers
scalingo scale web:2
# One-off command
scalingo run python manage.py shell
```
## Architecture
On Scalingo, the application runs as follows:
1. **Build Phase**: The buildpack compiles both frontend and backend
2. **Runtime**:
- Nginx serves static files and proxies to the backend
- Gunicorn runs the Django WSGI application
- Both processes are managed by the `bin/buildpack_start.sh` script
## Additional Resources
- [Scalingo Documentation](https://doc.scalingo.com/)
- [Suite Numérique Buildpack](https://github.com/suitenumerique/buildpack)
- [Meet Environment Variables](../../src/helm/meet/README.md)
- [Django Configurations Documentation](https://django-configurations.readthedocs.io/)
+1
View File
@@ -2,6 +2,7 @@
Gitlint extra rule to validate that the message title is of the form
"<gitmoji>(<scope>) <subject>"
"""
from __future__ import unicode_literals
import re
+21
View File
@@ -3,6 +3,21 @@
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
{
"groupName": "js dependencies",
"matchManagers": ["npm"],
"schedule": ["on the first day of the month"],
"matchPackagePatterns": ["*"],
"minimumReleaseAge": "7 days",
"internalChecksFilter": "strict"
},
{
"groupName": "python dependencies",
"matchManagers": ["setup-cfg", "pep621"],
"schedule": ["on the first day of the month"],
"matchPackagePatterns": ["*"],
"minimumReleaseAge": "7 days"
},
{
"enabled": false,
"groupName": "ignored python dependencies",
@@ -15,6 +30,12 @@
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
+5
View File
@@ -4,6 +4,8 @@ FROM python:3.13-slim AS base
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
"openssl=3.5.4-1~deb13u2" \
"libssl3t64=3.5.4-1~deb13u2" \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
@@ -19,6 +21,9 @@ FROM base AS production
WORKDIR /app
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
ARG DOCKER_USER
USER ${DOCKER_USER}
+3 -2
View File
@@ -1,14 +1,15 @@
[project]
name = "agents"
version = "1.5.0"
version = "1.9.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.3.10",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.1"
"python-dotenv==1.2.1",
"protobuf==6.33.5"
]
[project.optional-dependencies]
+17 -2
View File
@@ -115,6 +115,10 @@ class RoomAdmin(admin.ModelAdmin):
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
return super().get_queryset(request).prefetch_related("accesses__user")
def get_owner(self, obj):
"""Return the owner of the room for display in the admin list."""
@@ -138,6 +142,7 @@ class RecordingAccessInline(admin.TabularInline):
model = models.RecordingAccess
extra = 0
autocomplete_fields = ["user"]
@admin.action(description=_("Resend notification to external service"))
@@ -207,8 +212,18 @@ class RecordingAdmin(admin.ModelAdmin):
"created_at",
"worker_id",
)
list_filter = ["status", "room", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
list_filter = ["created_at"]
list_select_related = ("room",)
readonly_fields = (
"id",
"created_at",
"options",
"mode",
"room",
"status",
"updated_at",
"worker_id",
)
actions = [resend_notification]
def get_queryset(self, request):
+20 -2
View File
@@ -2,6 +2,8 @@
from rest_framework import permissions
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from ..models import RoleChoices
ACTION_FOR_METHOD_TO_PERMISSION = {
@@ -45,11 +47,27 @@ class RoomPermissions(permissions.BasePermission):
"""
def has_permission(self, request, view):
"""Only allow authenticated users for unsafe methods."""
"""Only allow authenticated users for unsafe methods.
Room creation additionally requires the can_create entitlement.
Fail-closed: denies creation when the entitlements service is unavailable.
"""
if request.method in permissions.SAFE_METHODS:
return True
return request.user.is_authenticated
if not request.user.is_authenticated:
return False
if view.action == "create":
try:
entitlements = get_user_entitlements(
request.user.sub, request.user.email
)
return entitlements.get("can_create", False)
except EntitlementsUnavailableError:
return False
return True
def has_object_permission(self, request, view, obj):
"""Object permissions are only given to administrators of the room."""
+101 -13
View File
@@ -1,15 +1,20 @@
"""Client serializers for the Meet core app."""
# pylint: disable=abstract-method,no-name-in-module
from typing import Literal
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from livekit.api import ParticipantPermission
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
class UserSerializer(serializers.ModelSerializer):
@@ -23,6 +28,25 @@ class UserSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "email", "full_name", "short_name"]
class UserMeSerializer(UserSerializer):
"""Serialize users for me endpoint."""
can_create = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = [*UserSerializer.Meta.fields, "can_create"]
read_only_fields = [*UserSerializer.Meta.read_only_fields, "can_create"]
def get_can_create(self, user) -> bool:
"""Check entitlements for the current user."""
try:
entitlements = get_user_entitlements(user.sub, user.email)
return entitlements.get("can_create", False)
except EntitlementsUnavailableError:
return False
class ResourceAccessSerializerMixin:
"""
A serializer mixin to share controlling that the logged-in user submitting a room access object
@@ -201,6 +225,27 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class RecordingOptions(BaseModel):
"""Configuration options for recording.
Attributes:
language: ISO 639-1 language code compatible with whisperX.
When `None`, the transcription engine will attempt to
auto-detect the spoken language.
transcribe: Whether to transcribe the recorded audio.
When `None`, falls back to the application default.
original_mode: The original recording mode before any override.
Must be one of the valid RecordingModeChoices values when provided.
"""
language: str | None = None
transcribe: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None
model_config = {"extra": "forbid"}
class StartRecordingSerializer(BaseValidationOnlySerializer):
"""Validate start recording requests."""
@@ -213,10 +258,11 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
"screen_recording or transcript.",
},
)
options = serializers.JSONField(
options = SchemaField(
schema=RecordingOptions | None,
required=False,
allow_null=True,
default=dict,
help_text="Recording options",
)
@@ -261,6 +307,28 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf.
Control what a participant is allowed to publish, subscribe, and do within a room.
Unknown fields are rejected.
"""
can_subscribe: bool | None = None
can_publish: bool | None = None
can_publish_data: bool | None = None
can_publish_sources: list[int] = Field(
default_factory=list
) # TrackSource enum values
hidden: bool | None = None
recorder: bool | None = None
can_update_metadata: bool | None = None
agent: bool | None = None
can_subscribe_metrics: bool | None = None
model_config = {"extra": "forbid"}
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
@@ -272,10 +340,11 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
allow_null=True,
help_text="Participant attributes as JSON object",
)
permission = serializers.DictField(
permission = SchemaField(
schema=ParticipantPermission | None,
required=False,
allow_null=True,
help_text="Participant permission as JSON object",
help_text="Participant permissions",
)
name = serializers.CharField(
max_length=255,
@@ -285,6 +354,33 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
help_text="Display name for the participant",
)
def validate_permission(self, permission):
"""Validate that the given permission does not include forbidden or unimplemented fields."""
if permission is None:
return None
suspicious_fields = [
field
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
if getattr(permission, field) is not None
]
if suspicious_fields:
raise SuspiciousOperation(
f"Setting the following participant permissions is not allowed: "
f"{', '.join(suspicious_fields)}."
)
if permission.can_subscribe_metrics is not None:
raise serializers.ValidationError(
{
"permission": {
"can_subscribe_metrics": "This permission is not implemented."
}
}
)
return permission
def validate(self, attrs):
"""Ensure at least one update field is provided."""
update_fields = ["metadata", "attributes", "permission", "name"]
@@ -300,12 +396,4 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
f"{', '.join(update_fields)}."
)
if "permission" in attrs:
try:
ParticipantPermission(**attrs["permission"])
except ValueError as e:
raise serializers.ValidationError(
{"permission": f"Invalid permission: {str(e)}"}
) from e
return attrs
+26
View File
@@ -0,0 +1,26 @@
"""Throttling modules for the API."""
from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle
from sentry_sdk import capture_message
def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues."""
capture_message(message, "warning")
class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
+12 -20
View File
@@ -10,7 +10,7 @@ from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.text import slugify
from rest_framework import decorators, mixins, pagination, throttling, viewsets
from rest_framework import decorators, mixins, pagination, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
@@ -58,7 +58,7 @@ from core.services.room_creation import RoomCreation
from core.services.subtitle import SubtitleException, SubtitleService
from ..authentication.livekit import LiveKitTokenAuthentication
from . import permissions, serializers
from . import permissions, serializers, throttling
from .feature_flag import FeatureFlag
# pylint: disable=too-many-ancestors
@@ -187,22 +187,10 @@ class UserViewSet(
"""
context = {"request": request}
return drf_response.Response(
self.serializer_class(request.user, context=context).data
serializers.UserMeSerializer(request.user, context=context).data
)
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
@@ -308,12 +296,14 @@ class RoomViewSet(
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data["options"]
options = serializer.validated_data.get("options")
room = self.get_object()
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room, mode=mode, options=options
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
)
models.RecordingAccess.objects.create(
@@ -379,7 +369,7 @@ class RoomViewSet(
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[RequestEntryAnonRateThrottle],
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
)
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
"""Request entry to a room"""
@@ -489,7 +479,7 @@ class RoomViewSet(
methods=["post"],
url_path="creation-callback",
permission_classes=[],
throttle_classes=[CreationCallbackAnonRateThrottle],
throttle_classes=[throttling.CreationCallbackAnonRateThrottle],
)
def creation_callback(self, request):
"""Retrieve cached room data via an unauthenticated request with a unique ID.
@@ -619,13 +609,15 @@ class RoomViewSet(
serializer = serializers.UpdateParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
permission = serializer.validated_data.get("permission")
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
metadata=serializer.validated_data.get("metadata"),
attributes=serializer.validated_data.get("attributes"),
permission=serializer.validated_data.get("permission"),
permission=permission.model_dump() if permission else None,
name=serializer.validated_data.get("name"),
)
except ParticipantsManagementException:
@@ -1,6 +1,7 @@
"""Authentication Backends for the Meet core app."""
import contextlib
import logging
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
@@ -10,6 +11,7 @@ from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import User
from core.services.marketing import (
ContactCreationError,
@@ -17,6 +19,8 @@ from core.services.marketing import (
get_marketing_service,
)
logger = logging.getLogger(__name__)
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -59,6 +63,21 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
# Warm the entitlements cache on login (force_refresh)
try:
get_user_entitlements(
user_sub=user.sub,
user_email=user.email,
user_info=claims,
force_refresh=True,
)
except EntitlementsUnavailableError:
email_domain = user.email.split("@")[-1] if "@" in user.email else "?"
logger.warning(
"Entitlements unavailable for user@%s during login",
email_domain,
)
@staticmethod
def signup_to_marketing_email(email):
"""Pragmatic approach to newsletter signup during authentication flow.
+29
View File
@@ -0,0 +1,29 @@
"""Entitlements service layer."""
from core.entitlements.factory import get_entitlements_backend
class EntitlementsUnavailableError(Exception):
"""Raised when the entitlements backend cannot be reached or returns an error."""
def get_user_entitlements(user_sub, user_email, user_info=None, force_refresh=False):
"""Get user entitlements, delegating to the configured backend.
Args:
user_sub: The user's OIDC subject identifier.
user_email: The user's email address.
user_info: The full OIDC user_info dict (forwarded to backend).
force_refresh: If True, bypass backend cache and fetch fresh data.
Returns:
dict: {"can_create": bool}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached
and no cache exists.
"""
backend = get_entitlements_backend()
return backend.get_user_entitlements(
user_sub, user_email, user_info=user_info, force_refresh=force_refresh
)
@@ -0,0 +1,27 @@
"""Abstract base class for entitlements backends."""
from abc import ABC, abstractmethod
class EntitlementsBackend(ABC):
"""Abstract base class that defines the interface for entitlements backends."""
@abstractmethod
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
"""Fetch user entitlements.
Args:
user_sub: The user's OIDC subject identifier.
user_email: The user's email address.
user_info: The full OIDC user_info dict (backends may
extract claims from it).
force_refresh: If True, bypass any cache and fetch fresh data.
Returns:
dict: {"can_create": bool}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached.
"""
@@ -0,0 +1,120 @@
"""DeployCenter (Espace Operateur) entitlements backend."""
import logging
from django.conf import settings
from django.core.cache import cache
import requests
from core.entitlements import EntitlementsUnavailableError
from core.entitlements.backends.base import EntitlementsBackend
logger = logging.getLogger(__name__)
class DeployCenterEntitlementsBackend(EntitlementsBackend):
"""Backend that fetches entitlements from the DeployCenter API.
Args:
base_url: Full URL of the entitlements endpoint
(e.g. "https://dc.example.com/api/v1.0/entitlements/").
service_id: The service identifier in DeployCenter.
api_key: API key for X-Service-Auth header.
timeout: HTTP request timeout in seconds.
oidc_claims: List of OIDC claim names to extract from user_info
and forward as query params (e.g. ["siret"]).
"""
def __init__( # pylint: disable=too-many-arguments
self,
base_url,
service_id,
api_key,
*,
timeout=10,
oidc_claims=None,
):
self.base_url = base_url
self.service_id = service_id
self.api_key = api_key
self.timeout = timeout
self.oidc_claims = oidc_claims or []
def _cache_key(self, user_sub):
return f"entitlements:user:{user_sub}"
def _make_request(self, user_email, user_info=None):
"""Make a request to the DeployCenter entitlements API.
Returns:
dict | None: The response data, or None on failure.
"""
params = {
"service_id": self.service_id,
"account_type": "user",
"account_email": user_email,
}
# Forward configured OIDC claims as query params
if user_info:
for claim in self.oidc_claims:
if claim in user_info:
params[claim] = user_info[claim]
headers = {
"X-Service-Auth": f"Bearer {self.api_key}",
}
try:
response = requests.get(
self.base_url,
params=params,
headers=headers,
timeout=self.timeout,
)
response.raise_for_status()
return response.json()
except (requests.RequestException, ValueError):
email_domain = user_email.split("@")[-1] if "@" in user_email else "?"
logger.warning(
"DeployCenter entitlements request failed for user@%s",
email_domain,
exc_info=True,
)
return None
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
"""Fetch user entitlements from DeployCenter with caching.
On cache miss or force_refresh: fetches from the API.
On API failure: falls back to stale cache if available,
otherwise raises EntitlementsUnavailableError.
"""
cache_key = self._cache_key(user_sub)
if not force_refresh:
cached = cache.get(cache_key)
if cached is not None:
return cached
data = self._make_request(user_email, user_info=user_info)
if data is None:
# API failed — try stale cache as fallback
cached = cache.get(cache_key)
if cached is not None:
return cached
raise EntitlementsUnavailableError(
"Failed to fetch user entitlements from DeployCenter"
)
entitlements = data.get("entitlements", {})
result = {
"can_create": entitlements.get("can_create", False),
}
cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT)
return result
@@ -0,0 +1,12 @@
"""Local entitlements backend for development and testing."""
from core.entitlements.backends.base import EntitlementsBackend
class LocalEntitlementsBackend(EntitlementsBackend):
"""Local backend that always grants access."""
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
return {"can_create": True}
+13
View File
@@ -0,0 +1,13 @@
"""Factory for creating entitlements backend instances."""
import functools
from django.conf import settings
from django.utils.module_loading import import_string
@functools.cache
def get_entitlements_backend():
"""Return a singleton instance of the configured entitlements backend."""
backend_class = import_string(settings.ENTITLEMENTS_BACKEND)
return backend_class(**settings.ENTITLEMENTS_BACKEND_PARAMETERS)
+146 -47
View File
@@ -1,25 +1,51 @@
"""Authentication Backends for external application to the Meet core app."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import jwt as pyJwt
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from rest_framework import authentication, exceptions
from core.models import Application
from core.services import jwt_token
User = get_user_model()
logger = logging.getLogger(__name__)
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
"""JWT authentication for application-delegated API access.
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
def __init__(
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
):
"""Initialize the JWT authentication backend with the given token service configuration.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm (e.g. HS256)
issuer: Expected token issuer identifier
audience: Expected token audience identifier
expiration_seconds: Token expiration time in seconds
token_type: Token type (e.g. Bearer)
"""
super().__init__()
self._token_service = jwt_token.JwtTokenService(
secret_key=secret_key,
algorithm=algorithm,
issuer=issuer,
audience=audience,
expiration_seconds=expiration_seconds,
token_type=token_type,
)
def authenticate(self, request):
"""Extract and validate JWT from Authorization header.
@@ -46,6 +72,78 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
return self.authenticate_credentials(token)
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict, or None if token is invalid
Raises:
AuthenticationFailed: If token is expired or has invalid issuer/audience
"""
try:
payload = self._token_service.decode_jwt(token)
return payload
except jwt_token.TokenExpiredError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except jwt_token.TokenInvalidError as e:
logger.warning("Invalid JWT issuer or audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except jwt_token.TokenDecodeError:
# Invalid JWT token - defer to next authentication backend
return None
def validate_payload(self, payload):
"""Validate JWT payload claims.
Override in subclasses to add custom validation.
Args:
payload: Decoded JWT payload
Raises:
AuthenticationFailed: If required claims are missing or invalid
"""
def get_user(self, payload):
"""Retrieve and validate user from payload.
Args:
payload: Decoded JWT payload
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
user_id = payload.get("user_id")
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.
@@ -60,60 +158,61 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
Raises:
AuthenticationFailed: If token is expired, or user not found
"""
# Decode and validate JWT
try:
payload = pyJwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
payload = self.decode_jwt(token)
if payload is None:
return None
user_id = payload.get("user_id")
self.validate_payload(payload)
user = self.get_user(payload)
return (user, payload)
class ApplicationJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for application-delegated API access.
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
def __init__(self):
"""Initialize authentication backend with application JWT settings from Django settings."""
super().__init__(
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
)
def validate_payload(self, payload):
"""Validate application-specific claims."""
client_id = payload.get("client_id")
is_delegated = payload.get("delegated", False)
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not client_id:
logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
application = Application.objects.get(client_id=client_id)
except Application.DoesNotExist as e:
logger.warning("Application not found: %s", client_id)
raise exceptions.AuthenticationFailed("Application not found.") from e
if not application.active:
logger.warning(
"Inactive application attempted authentication: %s", client_id
)
raise exceptions.AuthenticationFailed("Application is disabled.")
if not is_delegated:
logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return (user, payload)
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval."""
+27 -5
View File
@@ -33,12 +33,11 @@ class BaseScopePermission(permissions.BasePermission):
Raises:
PermissionDenied: If required scope is missing from token
"""
# Get the current action (e.g., 'list', 'create')
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
action = getattr(view, "action", None)
if not action:
raise exceptions.PermissionDenied(
"Insufficient permissions. Unknown action."
)
# DRF routers return a 405 for unsupported methods
return True
required_scope = self.scope_map.get(action)
if not required_scope:
@@ -57,9 +56,12 @@ class BaseScopePermission(permissions.BasePermission):
if isinstance(token_scopes, str):
token_scopes = token_scopes.split()
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
if settings.OIDC_RS_SCOPES_PREFIX:
token_scopes = [
scope.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
for scope in token_scopes
]
@@ -82,3 +84,23 @@ class HasRequiredRoomScope(BaseScopePermission):
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
"destroy": models.ApplicationScope.ROOMS_DELETE,
}
class RoomPermissions(permissions.BasePermission):
"""Permissions applying to the room API endpoint."""
def has_permission(self, request, view):
"""Allow access only to authenticated users."""
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Enforce role-based access: read=any role, delete=owner, write=admin or owner."""
user = request.user
if request.method in permissions.SAFE_METHODS:
return obj.has_any_role(user)
if request.method == "DELETE":
return obj.is_owner(user)
return obj.is_administrator_or_owner(user)
+20 -24
View File
@@ -1,6 +1,5 @@
"""External API endpoints"""
from datetime import datetime, timedelta, timezone
from logging import getLogger
from django.conf import settings
@@ -8,7 +7,6 @@ from django.contrib.auth.hashers import check_password
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.validators import validate_email
import jwt
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import decorators, mixins, viewsets
from rest_framework import (
@@ -22,6 +20,7 @@ from rest_framework import (
)
from core import api, models
from core.services.jwt_token import JwtTokenService
from . import authentication, permissions, serializers
@@ -128,33 +127,28 @@ class ApplicationViewSet(viewsets.ViewSet):
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": client_id,
"scope": scope,
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
token_service = JwtTokenService(
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
)
data = token_service.generate_jwt(
user,
scope,
{
"client_id": client_id,
"delegated": True,
},
)
return drf_response.Response(
{
"access_token": token,
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": scope,
},
data,
status=drf_status.HTTP_200_OK,
)
@@ -182,7 +176,9 @@ class RoomViewSet(
ResourceServerAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
api.permissions.IsAuthenticated
& permissions.HasRequiredRoomScope
& permissions.RoomPermissions
]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
+2 -2
View File
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='resource',
name='users',
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', to=settings.AUTH_USER_MODEL),
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='resourceaccess',
@@ -1,5 +1,5 @@
# Generated by Django 5.0.7 on 2024-08-07 14:39
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -1,5 +1,5 @@
# Generated by Django 5.1.8 on 2025-04-22 14:52
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
+4
View File
@@ -292,6 +292,10 @@ class Resource(BaseModel):
role = RoleChoices.MEMBER
return role
def has_any_role(self, user):
"""Check if a user has any role on the resource."""
return self.get_role(user) is not None
def is_administrator_or_owner(self, user):
"""
Check if a user is administrator or owner of the resource."""
@@ -167,6 +167,7 @@ class NotificationService:
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language,
}
headers = {
+153
View File
@@ -0,0 +1,153 @@
"""JWT token service."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
from datetime import datetime, timedelta, timezone
from typing import Optional
from django.core.exceptions import ImproperlyConfigured
import jwt
class JWTError(Exception):
"""Base exception for all JWT token errors."""
class TokenExpiredError(JWTError):
"""Raised when the JWT token has expired."""
class TokenInvalidError(JWTError):
"""Raised when the JWT token has an invalid issuer or audience."""
class TokenDecodeError(JWTError):
"""Raised for any other unrecoverable JWT decode failure."""
class JwtTokenService:
"""Generic JWT token service with configurable settings."""
def __init__(
self,
secret_key: str,
algorithm: str,
issuer: str,
audience: str,
expiration_seconds: int,
token_type: str,
):
"""
Initialize the token service with custom settings.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm
issuer: Token issuer identifier
audience: Token audience identifier
expiration_seconds: Token expiration time in seconds
token_type: Token type
Raises:
ImproperlyConfigured: If secret_key is None or empty
"""
if not secret_key:
raise ImproperlyConfigured("Secret key is required.")
if not algorithm:
raise ImproperlyConfigured("Algorithm is required.")
if not token_type:
raise ImproperlyConfigured("Token's type is required.")
if expiration_seconds is None:
raise ImproperlyConfigured("Expiration's seconds is required.")
self._key = secret_key
self._algorithm = algorithm
self._issuer = issuer
self._audience = audience
self._expiration_seconds = expiration_seconds
self._token_type = token_type
def generate_jwt(
self, user, scope: str, extra_payload: Optional[dict] = None
) -> dict:
"""
Generate an access token for the given user.
Note: any extra_payload variables named iat, exp, or user_id will
be overwritten by this service
Args:
user: User instance for whom to generate the token
scope: Space-separated scope string
Returns:
Dictionary containing access_token, token_type, expires_in, and scope optionally
"""
now = datetime.now(timezone.utc)
payload = extra_payload.copy() if extra_payload else {}
payload.update(
{
"iat": now,
"exp": now + timedelta(seconds=self._expiration_seconds),
"user_id": str(user.id),
}
)
if self._issuer:
payload["iss"] = self._issuer
if self._audience:
payload["aud"] = self._audience
if scope:
payload["scope"] = scope
token = jwt.encode(
payload,
self._key,
algorithm=self._algorithm,
)
response = {
"access_token": token,
"token_type": self._token_type,
"expires_in": self._expiration_seconds,
}
if scope:
response["scope"] = scope
return response
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict.
Raises:
TokenExpiredError: If the token has expired.
TokenInvalidError: If the token has an invalid issuer or audience.
TokenDecodeError: If the token is malformed or cannot be decoded.
"""
try:
payload = jwt.decode(
token,
self._key,
algorithms=[self._algorithm],
issuer=self._issuer,
audience=self._audience,
)
return payload
except jwt.ExpiredSignatureError as e:
raise TokenExpiredError("Token expired.") from e
except (jwt.InvalidIssuerError, jwt.InvalidAudienceError) as e:
raise TokenInvalidError("Invalid token.") from e
except jwt.InvalidTokenError as e:
raise TokenDecodeError("Token decode error.") from e
@@ -102,6 +102,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
settings.RECORDING_DOWNLOAD_BASE_URL = None
settings.EMAIL_FROM = "notifications@acme.com"
recording = factories.RecordingFactory(room__name="Conference Room A")
@@ -8,6 +8,7 @@ import random
from unittest import mock
from uuid import uuid4
from django.core.exceptions import SuspiciousOperation
from django.urls import reverse
import pytest
@@ -132,11 +133,7 @@ def test_update_participant_success(mock_livekit_client):
1,
2,
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"hidden": False,
"recorder": False,
"can_update_metadata": True,
"agent": False,
"can_subscribe_metrics": False,
},
"name": "John Doe",
}
@@ -151,6 +148,151 @@ def test_update_participant_success(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize(
"permission_payload",
[
{}, # empty dict is valid
{"can_subscribe": True},
{"can_publish": True},
{"can_publish_data": True},
{"can_publish_sources": [1, 2]},
{"can_update_metadata": True},
],
)
def test_update_participant_permission_fields_are_optional(
mock_livekit_client, permission_payload
):
"""Test that each required permission field can be passed individually."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": permission_payload,
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize(
"value,permission_key",
[
(False, "hidden"),
(True, "hidden"),
(False, "recorder"),
(True, "recorder"),
(False, "agent"),
(True, "agent"),
],
)
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_update_participant_suspicious_permission(
mock_suspicious, value, permission_key
):
"""Test update participant raises 400 when a restricted permission is set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_update_metadata": False,
permission_key: value,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_suspicious.assert_called_once_with(
f"Setting the following participant permissions is not allowed: {permission_key}."
)
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
def test_update_participant_suspicious_permission_multiple(mock_suspicious):
"""Test update participant raises 400 when multiple suspicious permissions are set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"hidden": True,
"recorder": False,
"can_update_metadata": False,
"agent": True,
"can_subscribe_metrics": False,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_suspicious.assert_called_once_with(
"Setting the following participant permissions is not allowed: hidden, recorder, agent."
)
@pytest.mark.parametrize("value", (False, True))
def test_update_participant_unimplemented_can_subscribe_metrics(value):
"""Test update participant raises 400 when can_subscribe_metrics is set."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_update_metadata": False,
"can_subscribe_metrics": value,
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "can_subscribe_metrics" in str(response.data)
def test_update_participant_forbidden_without_access():
"""Test update participant returns 403 when user lacks room privileges."""
client = APIClient()
@@ -226,7 +368,17 @@ def test_update_participant_invalid_permission():
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Invalid permission" in str(response.data)
assert response.json() == {
"permission": [
{
"type": "extra_forbidden",
"loc": ["invalid-attributes"],
"msg": "Extra inputs are not permitted",
"input": "True",
"url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
},
]
}
def test_update_participant_wrong_metadata_attributes():
@@ -199,3 +199,308 @@ def test_start_recording_success(
access = recording.accesses.first()
assert access.user == user
assert access.role == "owner"
@pytest.mark.parametrize("value", ["fr", "en", "nl", "de"])
def test_start_recording_options_language_valid(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept a valid ISO 639-1 language code."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"language": value}
@pytest.mark.parametrize("value", ["invalid-value", "francais", "123"])
def test_start_recording_options_language_not_validated(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Invalid language codes are currently accepted — no format validation yet.
TODO: tighten this once language validation is introduced.
"""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": value}},
format="json",
)
assert response.status_code == 201
def test_start_recording_options_language_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept null language (triggers auto-detection)."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
@pytest.mark.parametrize("value", [True, 1, "y", "on", "true", "yes", "t"])
def test_start_recording_options_transcribe_valid_true(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept transcribe with any valid pydantic true values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"transcribe": True}
@pytest.mark.parametrize("value", [False, 0, "n", "off", "false", "no", "f"])
def test_start_recording_options_transcribe_valid_false(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept transcribe with any valid pydantic false values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"transcribe": False}
def test_start_recording_options_transcribe_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept transcribe=null (falls back to application default)."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept options=null."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": None},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_omitted(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a request with no options field at all."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_unknown_field_rejected(settings):
"""Should reject unknown fields in options (extra='forbid')."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"unknown_field": "value"}},
format="json",
)
assert response.status_code == 400
@pytest.mark.parametrize("value", ["foo", 12])
def test_start_recording_options_invalid_transcribe_type(settings, value):
"""Should reject non-boolean transcribe values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 400
@pytest.mark.parametrize("value", ["screen_recording", "transcript"])
def test_start_recording_options_original_mode_valid(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept valid recording mode choices for original_mode."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"original_mode": value}
def test_start_recording_options_original_mode_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept original_mode=null."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_original_mode_omitted(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a request with original_mode omitted."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
def test_start_recording_options_original_mode_invalid(settings, value):
"""Should reject invalid recording mode values for original_mode."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": value}},
format="json",
)
assert response.status_code == 400
+1
View File
@@ -125,6 +125,7 @@ def test_api_users_retrieve_me_authenticated(settings):
"short_name": user.short_name,
"language": user.language,
"timezone": "UTC",
"can_create": True,
}
+504
View File
@@ -0,0 +1,504 @@
"""Tests for the entitlements module."""
# pylint: disable=redefined-outer-name
from unittest import mock
from django.test import override_settings
import pytest
import requests
import responses
from rest_framework.status import HTTP_201_CREATED, HTTP_403_FORBIDDEN
from rest_framework.test import APIClient
from django.core.cache import cache as django_cache
from core import factories
from core.api.serializers import UserMeSerializer
from core.authentication.backends import OIDCAuthenticationBackend
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.entitlements.backends.deploycenter import DeployCenterEntitlementsBackend
from core.entitlements.backends.local import LocalEntitlementsBackend
from core.entitlements.factory import get_entitlements_backend
pytestmark = pytest.mark.django_db
DC_URL = "https://deploy.example.com/api/v1.0/entitlements/"
@pytest.fixture(autouse=True)
def _clear_cache():
"""Clear Django cache between tests to prevent entitlements cache bleed."""
django_cache.clear()
# -- LocalEntitlementsBackend --
def test_local_backend_always_grants_access():
"""The local backend should always return can_create=True."""
backend = LocalEntitlementsBackend()
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_create": True}
def test_local_backend_ignores_parameters():
"""The local backend should work regardless of parameters passed."""
backend = LocalEntitlementsBackend()
result = backend.get_user_entitlements(
"sub-123",
"user@example.com",
user_info={"some": "claim"},
force_refresh=True,
)
assert result == {"can_create": True}
# -- Factory --
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_factory_returns_local_backend():
"""The factory should instantiate the configured backend."""
get_entitlements_backend.cache_clear()
backend = get_entitlements_backend()
assert isinstance(backend, LocalEntitlementsBackend)
get_entitlements_backend.cache_clear()
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_factory_singleton():
"""The factory should return the same instance on repeated calls."""
get_entitlements_backend.cache_clear()
backend1 = get_entitlements_backend()
backend2 = get_entitlements_backend()
assert backend1 is backend2
get_entitlements_backend.cache_clear()
# -- get_user_entitlements public API --
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_get_user_entitlements_with_local_backend():
"""The public API should delegate to the configured backend."""
get_entitlements_backend.cache_clear()
result = get_user_entitlements("sub-123", "user@example.com")
assert result["can_create"] is True
get_entitlements_backend.cache_clear()
# -- DeployCenterEntitlementsBackend --
@responses.activate
def test_deploycenter_backend_grants_access():
"""DeployCenter backend should return can_create from API response."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_create": True}
# Verify request was made with correct params and header
assert len(responses.calls) == 1
request = responses.calls[0].request
assert "service_id=meet" in request.url
assert "account_email=user%40example.com" in request.url
assert request.headers["X-Service-Auth"] == "Bearer test-key"
@responses.activate
def test_deploycenter_backend_denies_access():
"""DeployCenter backend should return can_create=False when API says so."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": False}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_create": False}
@responses.activate
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
def test_deploycenter_backend_uses_cache():
"""DeployCenter should use cached results when not force_refresh."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
# First call hits the API
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result1 == {"can_create": True}
assert len(responses.calls) == 1
# Second call should use cache
result2 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result2 == {"can_create": True}
assert len(responses.calls) == 1 # No additional API call
@responses.activate
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
def test_deploycenter_backend_force_refresh_bypasses_cache():
"""force_refresh=True should bypass cache and hit the API."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": False}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result1["can_create"] is True
result2 = backend.get_user_entitlements(
"sub-123", "user@example.com", force_refresh=True
)
assert result2["can_create"] is False
assert len(responses.calls) == 2
@responses.activate
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
def test_deploycenter_backend_fallback_to_stale_cache():
"""When API fails, should return stale cached value if available."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
# Populate cache
backend.get_user_entitlements("sub-123", "user@example.com")
# Now API fails
responses.replace(
responses.GET,
DC_URL,
body=requests.ConnectionError("Connection error"),
)
# force_refresh to hit API, but should fall back to cache
result = backend.get_user_entitlements(
"sub-123", "user@example.com", force_refresh=True
)
assert result == {"can_create": True}
@responses.activate
def test_deploycenter_backend_raises_when_no_cache():
"""When API fails and no cache exists, should raise."""
responses.add(
responses.GET,
DC_URL,
body=requests.ConnectionError("Connection error"),
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
with pytest.raises(EntitlementsUnavailableError):
backend.get_user_entitlements("sub-123", "user@example.com")
@responses.activate
def test_deploycenter_backend_sends_oidc_claims():
"""DeployCenter should forward configured OIDC claims."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
oidc_claims=["organization"],
)
backend.get_user_entitlements(
"sub-123",
"user@example.com",
user_info={"organization": "org-42", "other": "ignored"},
)
request = responses.calls[0].request
assert "organization=org-42" in request.url
assert "other" not in request.url
# -- Auth backend integration --
def test_auth_backend_warms_cache_on_login():
"""post_get_or_create_user should call get_user_entitlements with force_refresh."""
user = factories.UserFactory()
backend = OIDCAuthenticationBackend()
with mock.patch(
"core.authentication.backends.get_user_entitlements",
return_value={"can_create": True},
) as mock_ent:
backend.post_get_or_create_user(
user, {"email": user.email, "sub": "x"}, is_new_user=False
)
mock_ent.assert_called_once_with(
user_sub=user.sub,
user_email=user.email,
user_info={"email": user.email, "sub": "x"},
force_refresh=True,
)
def test_auth_backend_login_succeeds_when_access_denied():
"""Login should succeed even when can_create is False (gated in frontend)."""
user = factories.UserFactory()
backend = OIDCAuthenticationBackend()
with mock.patch(
"core.authentication.backends.get_user_entitlements",
return_value={"can_create": False},
):
# Should not raise — user logs in, frontend gates access
backend.post_get_or_create_user(
user, {"email": user.email}, is_new_user=False
)
def test_auth_backend_login_succeeds_when_entitlements_unavailable():
"""Login should succeed when entitlements service is unavailable."""
user = factories.UserFactory()
backend = OIDCAuthenticationBackend()
with mock.patch(
"core.authentication.backends.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
# Should not raise
backend.post_get_or_create_user(
user, {"email": user.email}, is_new_user=False
)
# -- UserMeSerializer (can_create field) --
def test_user_me_serializer_includes_can_create_true():
"""UserMeSerializer should include can_create=True when entitled."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
return_value={"can_create": True},
):
data = UserMeSerializer(user).data
assert data["can_create"] is True
def test_user_me_serializer_includes_can_create_false():
"""UserMeSerializer should include can_create=False when not entitled."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
return_value={"can_create": False},
):
data = UserMeSerializer(user).data
assert data["can_create"] is False
def test_user_me_serializer_can_create_fail_closed():
"""UserMeSerializer should return can_create=False when entitlements unavailable."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
data = UserMeSerializer(user).data
assert data["can_create"] is False
# -- /users/me/ endpoint integration --
def test_api_users_me_includes_can_create():
"""GET /users/me/ should include can_create in the response."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert "can_create" in response.json()
assert response.json()["can_create"] is True
def test_api_users_me_can_create_false():
"""GET /users/me/ should return can_create=False when not entitled."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.serializers.get_user_entitlements",
return_value={"can_create": False},
):
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert response.json()["can_create"] is False
# -- Room creation entitlements enforcement --
def test_room_creation_blocked_when_not_entitled():
"""Room creation should return 403 when user has can_create=False."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_create": False},
):
response = client.post(
"/api/v1.0/rooms/",
data={"name": "test-room"},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_room_creation_blocked_when_entitlements_unavailable():
"""Room creation should return 403 when entitlements service
is unavailable (fail-closed)."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
response = client.post(
"/api/v1.0/rooms/",
data={"name": "test-room"},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_room_creation_allowed_when_entitled():
"""Room creation should succeed when user has can_create=True."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_create": True},
):
response = client.post(
"/api/v1.0/rooms/",
data={"name": "test-room"},
format="json",
)
assert response.status_code == HTTP_201_CREATED
# -- Non-create room actions are NOT gated by entitlements --
def test_room_retrieve_allowed_when_not_entitled():
"""Room retrieval should work even when user has can_create=False."""
user = factories.UserFactory()
room = factories.RoomFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_create": False},
):
response = client.get(f"/api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
def test_room_list_allowed_when_not_entitled():
"""Room listing should work even when user has can_create=False."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_create": False},
):
response = client.get("/api/v1.0/rooms/")
assert response.status_code == 200
+617 -37
View File
@@ -2,21 +2,21 @@
Tests for external API /room endpoint
"""
# pylint: disable=W0621
# pylint: disable=W0621,C0302
import uuid
from datetime import datetime, timedelta, timezone
from unittest import mock
from django.conf import settings
import jwt
import pytest
import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import (
RoomFactory,
UserFactory,
)
from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
pytestmark = pytest.mark.django_db
@@ -27,12 +27,14 @@ def generate_test_token(user, scopes):
now = datetime.now(timezone.utc)
scope_string = " ".join(scopes)
application = ApplicationFactory()
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": "test-client-id",
"client_id": str(application.client_id),
"scope": scope_string,
"user_id": str(user.id),
"delegated": True,
@@ -53,10 +55,24 @@ def test_api_rooms_list_requires_authentication():
assert response.status_code == 401
def test_api_rooms_list_with_valid_token(settings):
"""Listing rooms with valid token should succeed."""
def test_api_rooms_list_inactive_user():
"""List should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False)
RoomFactory(users=[(user1, RoleChoices.OWNER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_list_with_valid_token():
"""Listing rooms with valid token should succeed."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
@@ -73,9 +89,25 @@ def test_api_rooms_list_with_valid_token(settings):
assert response.data["results"][0]["id"] == str(room.id)
def test_api_rooms_list_with_no_rooms():
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
user = UserFactory()
# Generate valid token
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 0
assert response.data["results"] == []
def test_api_rooms_list_with_expired_token(settings):
"""Listing rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
@@ -92,8 +124,8 @@ def test_api_rooms_list_with_expired_token(settings):
@responses.activate
def test_api_rooms_list_with_invalid_token(settings):
"""Listing rooms with invalid token should return 400."""
def test_api_rooms_list_with_invalid_rs_token(settings):
"""Listing rooms with invalid resource server token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
@@ -116,9 +148,8 @@ def test_api_rooms_list_with_invalid_token(settings):
assert response.status_code == 400
def test_api_rooms_list_missing_scope(settings):
def test_api_rooms_list_missing_scope():
"""Listing rooms without required scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -130,12 +161,30 @@ def test_api_rooms_list_missing_scope(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
assert (
"insufficient permissions. required scope: rooms:list"
in str(response.data).lower()
)
def test_api_rooms_list_filters_by_user(settings):
def test_api_rooms_list_no_scope():
"""Listing rooms without any scope should return 403."""
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_list_filters_by_user():
"""List should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
user2 = UserFactory()
@@ -144,7 +193,9 @@ def test_api_rooms_list_filters_by_user(settings):
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
token = generate_test_token(
user1, [ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
@@ -158,9 +209,82 @@ def test_api_rooms_list_filters_by_user(settings):
assert str(room2.id) not in returned_ids
def test_api_rooms_retrieve_requires_scope(settings):
def test_api_rooms_retrieve_requires_authentication():
"""Retrieving rooms without authentication should return 401."""
user1 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
client = APIClient()
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 401
def test_api_rooms_retrieve_inactive_user():
"""Retrieve should return 401 if user is inactive."""
user1 = UserFactory(is_active=False)
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_retrieve_with_expired_token(settings):
"""Retrieving rooms with expired token should return 401."""
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
@responses.activate
def test_api_rooms_retrieve_with_invalid_rs_token(settings):
"""Retrieving rooms with invalid resource server 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,
},
)
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
# 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_retrieve_requires_scope():
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
@@ -178,9 +302,25 @@ def test_api_rooms_retrieve_requires_scope(settings):
)
def test_api_rooms_retrieve_no_scope():
"""Retrieving rooms without any scope should return 403."""
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_retrieve_success(settings):
"""Retrieving a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = "http://your-application.com"
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
@@ -212,9 +352,128 @@ def test_api_rooms_retrieve_success(settings):
}
def test_api_rooms_create_requires_scope(settings):
def test_api_rooms_retrieve_success_by_user():
"""Retrieve should only return rooms accessible to the authenticated user."""
user1 = UserFactory()
user2 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
room4 = RoomFactory(users=[(user1, RoleChoices.ADMIN)])
token = generate_test_token(
user1, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_LIST]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room2.id}/")
assert response.status_code == 403
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 200
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room3.id}/")
assert response.status_code == 200
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room4.id}/")
assert response.status_code == 200
def test_api_rooms_retrieve_not_found():
"""Retrieving a non-existing room with correct scope should return a 404."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
assert response.status_code == 404
assert "no room matches the given query." in str(response.data).lower()
def test_api_rooms_create_requires_authentication():
"""Creating rooms without authentication should return 401."""
client = APIClient()
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
def test_api_rooms_create_with_expired_token(settings):
"""Creating rooms with expired token should return 401."""
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
@responses.activate
def test_api_rooms_create_with_invalid_rs_token(settings):
"""Creating rooms with invalid resource server 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.post("/external-api/v1.0/rooms/")
# 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_create_inactive_user():
"""Create should return 401 if user is inactive."""
user1 = UserFactory(is_active=False)
token = generate_test_token(user1, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_create_requires_scope():
"""Creating a room requires ROOMS_CREATE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without ROOMS_CREATE scope
@@ -225,18 +484,36 @@ def test_api_rooms_create_requires_scope(settings):
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:create" in str(
response.data
assert (
"insufficient permissions. required scope: rooms:create"
in str(response.data).lower()
)
def test_api_rooms_create_success(settings):
"""Creating a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
def test_api_rooms_create_no_scope():
"""Creating rooms without any scope should return 403."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
# Token without scope
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_create_success():
"""Creating a room with correct scope should succeed."""
user = UserFactory()
token = generate_test_token(
user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
@@ -245,6 +522,8 @@ def test_api_rooms_create_success(settings):
assert response.status_code == 201
assert "id" in response.data
assert "slug" in response.data
assert "name" in response.data
assert response.data["name"] == response.data["slug"]
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
@@ -252,9 +531,72 @@ def test_api_rooms_create_success(settings):
assert room.access_level == "trusted"
def test_api_rooms_create_readonly_enforcement():
"""Creating a room succeeds and any provided read-only fields are ignored."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{
"id": "fake-id",
"slug": "fake-slug",
"name": "fake-name",
"access_level": "public",
},
format="json",
)
assert response.status_code == 201
assert "slug" in response.data
assert response.data["id"] != "fake-id"
assert "name" in response.data
assert response.data["slug"] != "fake-slug"
assert "id" in response.data
assert response.data["name"] != "fake-name"
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert room.access_level == "trusted"
def test_api_rooms_unknown_actions():
"""Updating or deleting a room are not supported yet."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(
user,
[
ApplicationScope.ROOMS_RETRIEVE,
ApplicationScope.ROOMS_DELETE,
ApplicationScope.ROOMS_UPDATE,
],
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 405
assert 'method "delete" not allowed.' in str(response.data).lower()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 405
assert 'method "patch" not allowed.' in str(response.data).lower()
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = None
user = UserFactory()
@@ -273,7 +615,6 @@ def test_api_rooms_response_no_url(settings):
def test_api_rooms_response_no_telephony(settings):
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.ROOM_TELEPHONY_ENABLED = False
user = UserFactory()
@@ -290,10 +631,41 @@ def test_api_rooms_response_no_telephony(settings):
assert response.data["id"] == str(room.id)
def test_api_rooms_token_scope_case_insensitive(settings):
"""Token's scope should be case-insensitive."""
user = UserFactory()
application = ApplicationFactory()
# Generate token with mixed-case scope "Rooms:List" to verify that scope
# validation is case-insensitive (should match "rooms:list")
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "Rooms:List", # Mixed case - should be accepted as "rooms:list"
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
def test_api_rooms_token_without_delegated_flag(settings):
"""Token without delegated flag should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
@@ -302,7 +674,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "test-client",
"client_id": str(application.client_id),
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": False, # Not delegated
@@ -318,12 +690,75 @@ def test_api_rooms_token_without_delegated_flag(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "Invalid token type." in str(response.data)
assert "invalid token type." in str(response.data).lower()
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
"""Token signed with an invalid key should defer to the next authentication."""
user = UserFactory()
application = ApplicationFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
"invalid-private-key",
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
mock_rs_authenticate.assert_called()
assert response.status_code == 401
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
"""Token signed with an invalid alg should defer to the next authentication."""
settings.APPLICATION_JWT_ALG = "RS256"
user = UserFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "test-client",
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm="HS256", # different value
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
mock_rs_authenticate.assert_called()
assert response.status_code == 401
def test_api_rooms_token_missing_client_id(settings):
"""Token without client_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
now = datetime.now(timezone.utc)
@@ -348,7 +783,152 @@ def test_api_rooms_token_missing_client_id(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "Invalid token claims." in str(response.data)
assert "invalid token claims." in str(response.data).lower()
def test_api_rooms_token_missing_user_id(settings):
"""Token without user_id should be rejected."""
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "rooms:list",
"delegated": True,
# Missing user_id
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token claims." in str(response.data).lower()
def test_api_rooms_token_invalid_audience(settings):
"""Token with an invalid audience should be rejected."""
user = UserFactory()
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": "invalid-audience",
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(user.id),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token." in str(response.data).lower()
def test_api_rooms_token_unknown_user(settings):
"""Token for unknown user should be rejected."""
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user not found." in str(response.data).lower()
def test_api_rooms_token_unknown_application(settings):
"""Token for unknown application should be rejected."""
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "unknown-client-id",
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "application not found." in str(response.data).lower()
def test_api_rooms_token_inactive_application(settings):
"""Token for inactive application should be rejected."""
application = ApplicationFactory(active=False)
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "application is disabled." in str(response.data).lower()
@responses.activate
@@ -507,7 +1087,7 @@ def test_resource_server_authentication_successful(settings):
"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",
"scope": "openid lasuite_meet lasuite_meet:rooms:list lasuite_meet:rooms:retrieve",
"active": True,
},
)
@@ -21,7 +21,6 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
UserFactory(email="User.Family@example.com")
application = ApplicationFactory(
active=True,
@@ -173,9 +172,8 @@ def test_api_applications_generate_token_domain_not_authorized():
assert "not authorized for this email domain" in str(response.data)
def test_api_applications_generate_token_domain_authorized(settings):
def test_api_applications_generate_token_domain_authorized():
"""Application with domain authorization should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@allowed.com")
application = ApplicationFactory(
active=True,
@@ -230,7 +228,6 @@ def test_api_applications_generate_token_user_not_found():
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
@@ -280,7 +277,6 @@ def test_api_applications_token_payload_structure(settings):
def test_api_applications_token_new_user(settings):
"""Should create a new pending user when creation is allowed and user doesn't exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
@@ -337,7 +333,6 @@ def test_api_applications_token_new_user(settings):
def test_api_applications_token_existing_user(settings):
"""Application should not create a new user when user exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
settings.APPLICATION_ALLOW_USER_CREATION = True
+36 -2
View File
@@ -18,6 +18,7 @@ from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
@@ -92,7 +93,11 @@ class Base(Configuration):
# Database
DATABASES = {
"default": {
"default": dj_database_url.config()
if values.DatabaseURLValue(
None, environ_name="DATABASE_URL", environ_prefix=None
)
else {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
@@ -292,6 +297,9 @@ class Base(Configuration):
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
"core.api.throttling.sentry_monitoring_throttle_failure"
)
SPECTACULAR_SETTINGS = {
"TITLE": "Meet API",
@@ -588,6 +596,12 @@ class Base(Configuration):
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
)
# if provided, treat as suspicious (possible privilege escalation attempt).
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
["hidden", "recorder", "agent"],
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
environ_prefix=None,
)
# Recording settings
RECORDING_ENABLE = values.BooleanValue(
@@ -659,7 +673,7 @@ class Base(Configuration):
[],
environ_name="BREVO_API_CONTACT_LIST_IDS",
environ_prefix=None,
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
converter=int,
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
@@ -692,6 +706,23 @@ class Base(Configuration):
environ_prefix=None,
)
# Entitlements
ENTITLEMENTS_BACKEND = values.Value(
"core.entitlements.backends.local.LocalEntitlementsBackend",
environ_name="ENTITLEMENTS_BACKEND",
environ_prefix=None,
)
ENTITLEMENTS_BACKEND_PARAMETERS = values.DictValue(
{},
environ_name="ENTITLEMENTS_BACKEND_PARAMETERS",
environ_prefix=None,
)
ENTITLEMENTS_CACHE_TIMEOUT = values.PositiveIntegerValue(
300, # 5 minutes
environ_name="ENTITLEMENTS_CACHE_TIMEOUT",
environ_prefix=None,
)
# Calendar integrations
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
600, # 10 minutes
@@ -909,6 +940,9 @@ class Test(Base):
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
def __init__(self):
+28 -25
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.5.0"
version = "1.9.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,45 +17,48 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.13",
]
description = "A simple video and phone conferencing tool, powered by LiveKit"
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.13"
dependencies = [
"boto3==1.40.69",
"boto3==1.42.49",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.5.3",
"celery[redis]==5.6.2",
"dj-database-url==3.1.0",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.0.0",
"django-lasuite[all]==0.0.19",
"django-countries==8.2.0",
"django-lasuite[all]==0.0.24",
"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.9",
"django-pydantic-field==0.5.4",
"django==5.2.11",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
"dockerflow==2026.1.26",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"markdown==3.10",
"gunicorn==25.1.0",
"jsonschema==4.26.0",
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.2.12",
"PyJWT==2.10.1",
"psycopg[binary]==3.3.2",
"pydantic==2.12.4",
"PyJWT==2.11.0",
"python-frontmatter==1.1.0",
"requests==2.32.5",
"sentry-sdk==2.43.0",
"sentry-sdk==2.53.0",
"whitenoise==6.11.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"aiohttp==3.13.3",
]
@@ -68,21 +71,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2025.10.1",
"drf-spectacular-sidecar==2026.1.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"ipython==9.10.0",
"pyfakefs==6.1.1",
"pylint-django==2.7.0",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==9.0.0",
"pytest-django==4.12.0",
"pytest==9.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.4",
"types-requests==2.32.4.20250913",
"ruff==0.15.1",
"types-requests==2.32.4.20260107",
]
[tool.setuptools]
+2 -1
View File
@@ -43,7 +43,8 @@ RUN apk update && apk upgrade libssl3 \
libxml2>=2.12.7-r2 \
libxslt>=1.1.39-r2 \
libexpat>=2.7.2-r0 \
libpng>=1.6.53-r0
libpng>=1.6.53-r0 \
&& apk del curl
USER nginx
+6
View File
@@ -5,6 +5,12 @@ server {
root /usr/share/nginx/html;
location = /.well-known/windows-app-web-link {
default_type application/json;
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
add_header Content-Disposition "attachment; filename=windows-app-web-link";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
+3805 -1831
View File
File diff suppressed because it is too large Load Diff
+25 -25
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.5.0",
"version": "1.9.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,56 +13,56 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@fontsource/material-icons-outlined": "5.2.6",
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@livekit/components-react": "2.9.19",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.0",
"@pandacss/preset-panda": "1.8.2",
"@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.9.3",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@tanstack/react-query": "5.90.21",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"crisp-sdk-web": "1.0.27",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0",
"humanize-duration": "3.33.2",
"i18next": "25.8.8",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.7",
"posthog-js": "1.256.2",
"livekit-client": "2.17.1",
"posthog-js": "1.342.1",
"react": "18.3.1",
"react-aria-components": "1.10.1",
"react-aria-components": "1.14.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.1.5",
"wouter": "3.7.1"
"valtio": "2.3.0",
"wouter": "3.9.0"
},
"devDependencies": {
"@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5",
"@pandacss/dev": "1.8.2",
"@tanstack/eslint-plugin-query": "5.91.4",
"@tanstack/react-query-devtools": "5.91.3",
"@types/humanize-duration": "3.27.4",
"@types/node": "22.16.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"@vitejs/plugin-react": "4.6.0",
"@vitejs/plugin-react": "5.1.4",
"eslint": "8.57.0",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier": "3.8.1",
"typescript": "5.8.3",
"vite": "7.0.8",
"vite-tsconfig-paths": "5.1.4"
"vite": "7.3.1",
"vite-tsconfig-paths": "6.1.1"
}
}
@@ -0,0 +1,6 @@
[
{
"packageFamilyName" : "Visio_g3z6ba6vek6vg",
"paths" : [ "*" ]
}
]
@@ -7,4 +7,5 @@ export type ApiUser = {
last_name: string
language: BackendLanguage
timezone: string
can_create?: boolean
}
@@ -4,6 +4,7 @@ import { Button } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const Heading = styled('h2', {
base: {
@@ -144,6 +145,21 @@ type Slide = {
isAvailableInBeta?: boolean
}
const carouselNavButton = css({
_focusVisible: {
outline: '2px solid var(--colors-focus-ring) !important',
outlineOffset: '1px',
},
_disabled: {
color: 'greyscale.400',
cursor: 'default',
pointerEvents: 'none',
_pressed: {
backgroundColor: 'transparent',
},
},
})
// todo - optimize how images are imported
const SLIDES: Slide[] = [
{
@@ -163,11 +179,45 @@ const SLIDES: Slide[] = [
export const IntroSlider = () => {
const [slideIndex, setSlideIndex] = useState(0)
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
const announce = useScreenReaderAnnounce()
const NUMBER_SLIDES = SLIDES.length
const goPrev = () => {
if (slideIndex === 0) return
const newIndex = slideIndex - 1
setSlideIndex(newIndex)
announce(
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
'polite',
'global'
)
}
const goNext = () => {
if (slideIndex === NUMBER_SLIDES - 1) return
const newIndex = slideIndex + 1
setSlideIndex(newIndex)
announce(
t('slidePosition', { current: newIndex + 1, total: NUMBER_SLIDES }),
'polite',
'global'
)
}
const ariaLabelParams = {
current: slideIndex + 1,
total: NUMBER_SLIDES,
}
const previousAriaLabel = t('previous.labelWithPosition', ariaLabelParams)
const nextAriaLabel = t('next.labelWithPosition', ariaLabelParams)
return (
<Container>
<Container
role="region"
aria-roledescription="carousel"
aria-label={t('carouselLabel')}
>
<div
className={css({
display: 'flex',
@@ -180,10 +230,10 @@ export const IntroSlider = () => {
<Button
variant="secondaryText"
square
aria-label={t('previous.label')}
tooltip={t('previous.tooltip')}
onPress={() => setSlideIndex(slideIndex - 1)}
isDisabled={slideIndex == 0}
className={carouselNavButton}
aria-label={previousAriaLabel}
aria-disabled={slideIndex === 0}
onPress={goPrev}
>
<RiArrowLeftSLine />
</Button>
@@ -191,7 +241,11 @@ export const IntroSlider = () => {
</ButtonContainer>
<SlideContainer>
{SLIDES.map((slide, index) => (
<Slide visible={index == slideIndex} key={index}>
<Slide
aria-hidden={index !== slideIndex}
visible={index === slideIndex}
key={index}
>
<Image src={slide.src} alt="" role="presentation" />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
@@ -205,10 +259,10 @@ export const IntroSlider = () => {
<Button
variant="secondaryText"
square
aria-label={t('next.label')}
tooltip={t('next.tooltip')}
onPress={() => setSlideIndex(slideIndex + 1)}
isDisabled={slideIndex == NUMBER_SLIDES - 1}
className={carouselNavButton}
aria-label={nextAriaLabel}
aria-disabled={slideIndex === NUMBER_SLIDES - 1}
onPress={goNext}
>
<RiArrowRightSLine />
</Button>
@@ -5,37 +5,42 @@ import { isRoomValid } from '@/features/rooms'
export const JoinMeetingDialog = () => {
const { t } = useTranslation('home')
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
const roomId = (data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
navigateTo('room', roomId)
}
const validateRoomId = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return null
return !isRoomValid(trimmed) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}
return (
<Dialog title={t('joinMeeting')}>
<Form
onSubmit={(data) => {
navigateTo(
'room',
(data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
)
}}
submitLabel={t('joinInputSubmit')}
>
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
<Field
type="text"
autoFocus
isRequired
name="roomId"
label={t('joinInputLabel')}
description={t('joinInputExample', {
example: window.origin + '/azer-tyu-qsdf',
})}
validate={(value) => {
return !isRoomValid(value.trim()) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}}
validate={validateRoomId}
/>
</Form>
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
+52 -40
View File
@@ -148,7 +148,8 @@ const IntroText = styled('div', {
export const Home = () => {
const { t } = useTranslation('home')
const { isLoggedIn } = useUser()
const { isLoggedIn, user } = useUser()
const canCreate = user?.can_create === true
const {
userChoices: { username },
@@ -200,45 +201,56 @@ export const Home = () => {
})}
>
{isLoggedIn ? (
<Menu>
<Button variant="primary" data-attr="create-meeting">
{t('createMeeting')}
</Button>
<RACMenu>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-instant"
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
</MenuItem>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoom(data)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
</RACMenu>
</Menu>
canCreate ? (
<Menu>
<Button variant="primary" data-attr="create-meeting">
{t('createMeeting')}
</Button>
<RACMenu>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-instant"
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
</MenuItem>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoom(data)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
<p
className={css({
color: 'greyscale.700',
fontSize: '0.95rem',
})}
>
{t('noAccess')}
</p>
)
) : (
<LoginButton proConnectHint={false} />
)}
@@ -25,15 +25,27 @@ export const TermsOfServiceRoute = () => {
{/* Article 2 */}
<H lvl={2}>{t('articles.article2.title')}</H>
<P>{t('articles.article2.content')}</P>
<P>{t('articles.article2.purposes')}</P>
{ensureArray(
t('articles.article2.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Article 3 */}
<H lvl={2}>{t('articles.article3.title')}</H>
<P>{t('articles.article3.definition')}</P>
{ensureArray(
t('articles.article3.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Article 4 */}
<H lvl={2}>{t('articles.article4.title')}</H>
<P>{t('articles.article4.content')}</P>
<P>{t('articles.article4.definition')}</P>
{/* Article 5 */}
<H lvl={2} margin={false}>
@@ -62,6 +74,7 @@ export const TermsOfServiceRoute = () => {
__html: t('articles.article5.sections.section1.paragraph3'),
}}
></P>
<P>{t('articles.article5.sections.section1.paragraph4')}</P>
{/* Section 5.2 */}
<H lvl={3} bold>
@@ -112,7 +125,6 @@ export const TermsOfServiceRoute = () => {
{/* Article 7 */}
<H lvl={2}>{t('articles.article7.title')}</H>
<P>{t('articles.article7.content')}</P>
{/* Section 7.1 */}
<H lvl={3} bold>
@@ -65,7 +65,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
alignItems="left"
justify="start"
gap={0}
style={{ maxWidth: '100%', overflow: 'hidden' }}
style={{ maxWidth: '100%', overflow: 'visible' }}
>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
{t('heading')}
@@ -93,7 +93,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'hidden',
overflow: 'visible',
})}
>
<div
@@ -753,7 +753,7 @@ export const Join = ({
try {
saveVideoInputDeviceId(id)
if (videoTrack) {
await await videoTrack.setDeviceId({ exact: id })
await videoTrack.setDeviceId({ exact: id })
}
} catch (err) {
console.error('Failed to switch camera device', err)
@@ -4,7 +4,7 @@ import { cva } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { styled, VStack } from '@/styled-system/jsx'
import { usePostHog } from 'posthog-js/react'
import { PostHog } from 'posthog-js'
import type { PostHog } from 'posthog-js'
import { Button as RACButton } from 'react-aria-components'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
@@ -74,13 +74,17 @@ export const useWaitingParticipants = () => {
): Promise<void> => {
try {
setListEnabled(false)
for (const participant of waitingParticipants) {
await enterRoom({
roomId: roomId,
allowEntry,
participantId: participant.id,
})
}
await Promise.all(
waitingParticipants.map((participant) =>
enterRoom({
roomId: roomId,
allowEntry,
participantId: participant.id,
})
)
)
await refetchWaiting()
} catch (e) {
console.error(e)
@@ -1,4 +1,4 @@
import { Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
import { A, Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio'
@@ -9,15 +9,18 @@ import { navigateTo } from '@/navigation/navigateTo'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
const FINAL_COUNTDOWN_SECONDS = 10
export const IsIdleDisconnectModal = () => {
const connectionObserverSnap = useSnapshot(connectionObserverStore)
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
const lastAnnouncementRef = useRef<number | null>(null)
const { openSettingsDialog } = useSettingsDialog()
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
const announce = useScreenReaderAnnounce()
@@ -55,7 +58,7 @@ export const IsIdleDisconnectModal = () => {
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
const shouldAnnounce =
COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
COUNTDOWN_ANNOUNCEMENT_SECONDS.has(remainingSeconds) ||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
@@ -117,7 +120,19 @@ export const IsIdleDisconnectModal = () => {
}),
})}
</P>
<P>{t('settings')}</P>
<P>
{t('settingsPrefix')}{' '}
<A
color="primary"
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
openSettingsDialog(SettingsDialogExtendedKey.GENERAL)
}}
>
{t('settingsLink')}
</A>
{t('settingsSuffix')}
</P>
<HStack marginTop="2rem">
<Button
onPress={() => {
@@ -1,5 +1,28 @@
import React, { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx'
const Hint = styled('div', {
base: {
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',
opacity: 0,
visibility: 'hidden',
pointerEvents: 'none',
transition: 'opacity 150ms ease',
'.lk-grid-layout > *:first-child:focus-within &': {
opacity: 1,
visibility: 'visible',
pointerEvents: 'auto',
},
},
})
export interface KeyboardShortcutHintProps {
children: ReactNode
@@ -12,21 +35,5 @@ export interface KeyboardShortcutHintProps {
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>
)
return <Hint>{children}</Hint>
}
@@ -1,8 +1,21 @@
import type { CSSProperties } from 'react'
import { Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useParticipantInfo } from '@livekit/components-react'
import { Participant } from 'livekit-client'
const participantNameStyles: CSSProperties = {
paddingBottom: '0.1rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
const participantNameScreenShareStyles: CSSProperties = {
...participantNameStyles,
marginLeft: '0.4rem',
}
export const ParticipantName = ({
participant,
isScreenShare = false,
@@ -17,26 +30,14 @@ export const ParticipantName = ({
if (isScreenShare) {
return (
<Text
variant="sm"
style={{
paddingBottom: '0.1rem',
marginLeft: '0.4rem',
}}
>
<Text variant="sm" style={participantNameScreenShareStyles}>
{t('screenShare', { name: displayedName })}
</Text>
)
}
return (
<Text
variant="sm"
style={{
paddingBottom: '0.1rem',
}}
aria-hidden="true"
>
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
{displayedName}
</Text>
)
@@ -31,16 +31,7 @@ import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next'
import { ShortcutHelpTooltip } from './ShortcutHelpTooltip'
import { isMacintosh } from '@/utils/livekit'
import {
loadShortcutOverrides,
shortcutOverridesStore,
} from '@/stores/shortcutOverrides'
import { useSnapshot } from 'valtio'
import { getShortcutById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
import { participantLayoutStore } from '@/stores/participantLayout'
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -118,26 +109,6 @@ export const ParticipantTile: (
const participantName = getParticipantName(trackReference.participant)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
loadShortcutOverrides()
const { overrides } = useSnapshot(shortcutOverridesStore)
const openShortcutDefault = getShortcutById('open-shortcuts')?.shortcut
const openShortcutEffective =
overrides.get('open-shortcuts') ?? openShortcutDefault
const openShortcutLabel =
formatShortcutLabel(openShortcutEffective) ??
`${isMacintosh() ? '⌘' : 'Ctrl'} + /`
// Check if we should show the shortcut helper tooltip
// Only show on the first tile when in grid layout (not in carousel/left column, not in focus mode)
const { layoutType, firstGridTileTrackId } = useSnapshot(participantLayoutStore)
const currentTrackId = isTrackReference(trackReference)
? `${trackReference.participant.sid}-${trackReference.source}`
: null
const shouldShowShortcutHelper =
hasKeyboardFocus &&
layoutType === 'grid' && // Only show in grid layout
currentTrackId === firstGridTileTrackId // Only show on first tile
const interactiveProps = {
...elementProps,
@@ -212,7 +183,7 @@ export const ParticipantTile: (
}}
>
{isHandRaised && !isScreenShare && (
<>
<span>
<span>{positionInQueue}</span>
<RiHand
color="black"
@@ -226,7 +197,7 @@ export const ParticipantTile: (
animationIterationCount: '2',
}}
/>
</>
</span>
)}
{isScreenShare && (
<ScreenShareIcon
@@ -239,10 +210,12 @@ export const ParticipantTile: (
{isEncrypted && !isScreenShare && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
)}
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</div>
</HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
@@ -258,14 +231,7 @@ export const ParticipantTile: (
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
{shouldShowShortcutHelper && (
<ShortcutHelpTooltip
triggerLabel={t('toolbarHint', {
binding: openShortcutLabel,
})}
isVisible={hasKeyboardFocus}
/>
)}
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
</div>
)
})
@@ -1,211 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { css } from '@/styled-system/css'
import { shortcutCatalog } from '@/features/shortcuts/catalog'
import { ShortcutCategorySection } from '@/features/shortcuts/components'
import { useFocusTrap } from '@/features/shortcuts/useFocusTrap'
import {
closeShortcutHelp,
shortcutHelpStore,
toggleShortcutHelp,
} from '@/stores/shortcutHelp'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'react-i18next'
import { Shortcut } from '@/features/shortcuts/types'
import {
loadShortcutOverrides,
shortcutOverridesStore,
} from '@/stores/shortcutOverrides'
type ShortcutHelpTooltipProps = {
triggerLabel: string
isVisible?: boolean
}
const containerStyle = css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
backgroundColor: 'rgba(0,0,0,0.65)',
paddingInline: '0.5rem',
paddingBlock: '0.25rem',
fontSize: '0.875rem',
minWidth: '200px',
maxWidth: '320px',
boxShadow: '0 4px 10px rgba(0,0,0,0.25)',
zIndex: 10,
opacity: 0,
visibility: 'hidden',
pointerEvents: 'none',
transition: 'opacity 150ms ease',
'&[data-visible="true"]': {
opacity: 1,
visibility: 'visible',
pointerEvents: 'auto',
},
})
const triggerStyle = css({
all: 'unset',
cursor: 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
color: 'white',
fontWeight: 600,
gap: '0.5rem',
outline: 'none',
'&:focus-visible': {
outline: '2px solid rgba(255,255,255,0.5)',
outlineOffset: '2px',
borderRadius: '6px',
},
})
const panelStyle = css({
overflow: 'hidden',
maxHeight: '0px',
opacity: 0,
marginTop: '0',
transition:
'max-height 200ms ease, opacity 200ms ease, margin-top 200ms ease',
'&[data-open="true"]': {
maxHeight: '400px',
opacity: 1,
marginTop: '0.35rem',
overflowY: 'auto',
paddingRight: '0.25rem',
},
})
const emptyStateStyle = css({
fontSize: '0.85rem',
opacity: 0.8,
})
export const ShortcutHelpTooltip: React.FC<ShortcutHelpTooltipProps> = ({
triggerLabel,
isVisible = false,
}) => {
const { t } = useTranslation('rooms')
const { isOpen } = useSnapshot(shortcutHelpStore)
loadShortcutOverrides()
const { overrides } = useSnapshot(shortcutOverridesStore)
const isContainerVisible = isVisible || isOpen
const triggerRef = useRef<HTMLButtonElement | null>(null)
const panelRef = useRef<HTMLDivElement | null>(null)
const wasOpenRef = useRef(false)
useFocusTrap(panelRef, { isActive: isOpen, fallbackRef: panelRef })
const grouped = useMemo(() => {
return shortcutCatalog.reduce<
Record<
string,
{
item: (typeof shortcutCatalog)[number]
effective: Shortcut | undefined
}[]
>
>((acc, item) => {
const effective = overrides.get(item.id) ?? item.shortcut
acc[item.category] = acc[item.category] || []
acc[item.category].push({ item, effective })
return acc
}, {})
}, [overrides])
const entries = Object.entries(grouped)
const hasItems = entries.length > 0
const getCategoryLabel = useCallback(
(category: string) => t(`shortcutsPanel.categories.${category}`),
[t]
)
const getActionLabel = useCallback(
(id: string) => t(`shortcutsPanel.actions.${id}`),
[t]
)
const handleToggle = useCallback(() => {
toggleShortcutHelp()
}, [])
useEffect(() => {
if (!isOpen) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeShortcutHelp()
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
}
}, [isOpen])
useEffect(() => {
if (isOpen) {
panelRef.current?.focus()
} else if (wasOpenRef.current && !isOpen && triggerRef.current) {
triggerRef.current.focus()
}
wasOpenRef.current = isOpen
}, [isOpen])
return (
<div data-visible={isContainerVisible} className={containerStyle}>
<button
type="button"
aria-expanded={isOpen}
aria-controls="shortcut-help-panel"
onClick={handleToggle}
ref={triggerRef}
className={triggerStyle}
>
<span>{triggerLabel}</span>
<span
aria-hidden
className={css({
fontSize: '0.75rem',
opacity: 0.8,
})}
>
{isOpen ? '▾' : '▸'}
</span>
</button>
<div
id="shortcut-help-panel"
role="dialog"
aria-modal="false"
aria-labelledby="shortcut-help-title"
data-open={isOpen}
ref={panelRef}
tabIndex={-1}
className={panelStyle}
>
<h2 id="shortcut-help-title" className="sr-only">
{t('shortcutsPanel.title')}
</h2>
{hasItems ? (
entries.map(([category, items]) => (
<ShortcutCategorySection
key={category}
category={category}
items={items}
getCategoryLabel={getCategoryLabel}
getActionLabel={getActionLabel}
/>
))
) : (
<div className={emptyStateStyle}>
{t('shortcutsPanel.noShortcuts')}
</div>
)}
</div>
</div>
)
}
@@ -11,9 +11,7 @@ import {
ProcessorType,
} from '.'
export class UnifiedBackgroundTrackProcessor
implements BackgroundProcessorInterface
{
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
processor: ProcessorWrapper<BackgroundOptions>
opts: BackgroundOptions
processorType: ProcessorType
@@ -13,8 +13,7 @@ export interface ProcessorSerialized {
options: BackgroundOptions
}
export interface BackgroundProcessorInterface
extends TrackProcessor<Track.Kind> {
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): Promise<void>
options: BackgroundOptions
clone(): BackgroundProcessorInterface
@@ -20,8 +20,8 @@ export const ChatToggle = ({
const tooltipLabel = isChatOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
shortcutId: 'toggle-chat',
handler: async () => toggleChat(),
id: 'toggle-chat',
handler: toggleChat,
})
return (
@@ -1,10 +1,8 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useMemo, useState } from 'react'
import {
appendShortcutLabel,
getEffectiveShortcut,
} from '@/features/shortcuts/utils'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { PermissionNeededButton } from './PermissionNeededButton'
import useLongPress from '@/features/shortcuts/useLongPress'
@@ -19,10 +17,9 @@ import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
import { getShortcutById } from '@/features/shortcuts/catalog'
import { useSnapshot } from 'valtio'
import { shortcutOverridesStore } from '@/stores/shortcutOverrides'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
type ToggleDeviceStyleProps = {
variant?: NonNullable<ButtonRecipeProps>['variant']
@@ -90,34 +87,43 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const shortcutId =
kind === 'audioinput' ? 'toggle-microphone' : 'toggle-camera'
const { overrides } = useSnapshot(shortcutOverridesStore)
const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce()
useRegisterKeyboardShortcut({
shortcutId,
handler: async () => await toggle(),
id: deviceShortcut?.id,
handler: async () => {
const nextState = !enabled
try {
const didChange = await toggle(nextState)
if (didChange === false) return
const message = t(nextState ? 'turnedOn' : 'turnedOff', {
keyPrefix: `selectDevice.${kind}`,
})
announce(message, 'assertive')
} catch {
// no announce
}
},
isDisabled: cannotUseDevice,
})
const pushToTalkShortcut = getShortcutDescriptorById('push-to-talk')
useLongPress({
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
keyCode: kind === 'audioinput' ? pushToTalkShortcut?.code : undefined,
onKeyDown,
onKeyUp,
isDisabled: cannotUseDevice,
})
const deviceShortcut = useMemo(() => {
const override = overrides.get(shortcutId)
if (override) return override
const catalogItem = getShortcutById(shortcutId)
return catalogItem?.shortcut
}, [shortcutId, overrides])
const toggleLabel = useMemo(() => {
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `selectDevice.${kind}`,
})
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
return deviceShortcut?.shortcut
? appendShortcutLabel(label, deviceShortcut.shortcut)
: label
}, [enabled, kind, deviceShortcut, t])
const Icon =
@@ -17,7 +17,6 @@ import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -100,14 +99,6 @@ export const VideoDeviceControl = ({
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA)
useRegisterKeyboardShortcut({
shortcut: { key: 'V', ctrlKey: true, shiftKey: true },
handler: async () => {
if (!canPublishTrack || cannotUseDevice) return
await toggleWithProcessor()
},
})
return (
<div
className={css({
@@ -29,24 +29,21 @@ export const HandToggle = () => {
setHasShownToast(false)
}
useEffect(() => {
if (isHandRaised) return
closeLowerHandToasts()
}, [isHandRaised])
const handleToggle = () => {
toggleRaisedHand()
resetToastState()
}
useRegisterKeyboardShortcut({
shortcutId: 'raise-hand',
handler: async () => {
toggleRaisedHand()
resetToastState()
},
id: 'raise-hand',
handler: handleToggle,
})
useEffect(() => {
if (isHandRaised) return
closeLowerHandToasts()
}, [isHandRaised])
useEffect(() => {
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
@@ -29,8 +29,8 @@ export const ParticipantsToggle = ({
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
shortcutId: 'toggle-participants',
handler: async () => toggleParticipants(),
id: 'toggle-participants',
handler: toggleParticipants,
})
return (
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { useState, useRef, useEffect } from 'react'
import { useState, useRef } from 'react'
import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { ToggleButton, Button } from '@/primitives'
@@ -11,10 +11,15 @@ import {
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import {
Popover as RACPopover,
Dialog,
DialogTrigger,
} from 'react-aria-components'
import { FocusScope } from '@react-aria/focus'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
// eslint-disable-next-line react-refresh/only-export-components
export enum Emoji {
@@ -40,11 +45,11 @@ export const ReactionsToggle = () => {
const instanceIdRef = useRef(0)
const room = useRoomContext()
const [isVisible, setIsVisible] = useState(false)
const [isOpen, setIsOpen] = useState(false)
useRegisterKeyboardShortcut({
shortcutId: 'reaction',
handler: async () => setIsVisible((prev) => !prev),
id: 'reaction',
handler: () => setIsOpen((prev) => !prev),
})
const sendReaction = async (emoji: string) => {
@@ -79,100 +84,76 @@ export const ReactionsToggle = () => {
windowMs: 1000,
})
// Custom animation implementation for the emoji toolbar
// Could not use a menu and its animation, because a menu would make the toolbar inaccessible by keyboard
// animation isn't perfect
const [isRendered, setIsRendered] = useState(isVisible)
const [opacity, setOpacity] = useState(isVisible ? 1 : 0)
useEffect(() => {
if (isVisible) {
// Show: first render, then animate in
setIsRendered(true)
// Need to delay setting opacity to ensure CSS transition works
// (using requestAnimationFrame to ensure DOM has updated)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setOpacity(1)
})
})
} else if (isRendered) {
// Hide: first animate out, then unrender
setOpacity(0)
// Wait for animation to complete before removing from DOM
const timer = setTimeout(() => {
setIsRendered(false)
}, 200) // Match this to your animation duration
return () => clearTimeout(timer)
}
}, [isVisible, isRendered])
return (
<>
<div
className={css({
position: 'relative',
})}
>
<ToggleButton
square
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
onPress={() => setIsVisible(!isVisible)}
>
<RiEmotionLine />
</ToggleButton>
{isRendered && (
<div
<div className={css({ position: 'relative' })}>
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<ToggleButton
square
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
isSelected={isOpen}
onChange={setIsOpen}
>
<RiEmotionLine />
</ToggleButton>
<RACPopover
placement="top"
offset={8}
isNonModal
shouldCloseOnInteractOutside={() => false}
className={css({
position: 'absolute',
top: -63,
left: -162,
borderRadius: '8px',
padding: '0.35rem',
backgroundColor: 'primaryDark.50',
opacity: opacity,
transition: 'opacity 0.2s ease',
'&[data-entering]': {
animation: 'fade 200ms ease',
},
'&[data-exiting]': {
animation: 'fade 200ms ease-in reverse',
},
})}
onTransitionEnd={() => {
if (!isVisible) {
setIsRendered(false)
}
}}
>
<RACToolbar
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
{Object.values(Emoji).map((emoji, index) => (
<Button
key={index}
onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
variant="primaryTextDark"
size="sm"
square
data-attr={`send-reaction-${emoji}`}
<Dialog className={css({ outline: 'none' })}>
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
<FocusScope contain autoFocus restoreFocus>
<div
role="toolbar"
aria-orientation="horizontal"
aria-label={t('button')}
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
<img
src={`/assets/reactions/${emoji}.png`}
alt=""
className={css({
minHeight: '28px',
minWidth: '28px',
pointerEvents: 'none',
userSelect: 'none',
})}
/>
</Button>
))}
</RACToolbar>
</div>
)}
{Object.values(Emoji).map((emoji, index) => (
<Button
key={index}
onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
variant="primaryTextDark"
size="sm"
square
data-attr={`send-reaction-${emoji}`}
>
<img
src={`/assets/reactions/${emoji}.png`}
alt=""
className={css({
width: '28px',
height: '28px',
pointerEvents: 'none',
userSelect: 'none',
})}
/>
</Button>
))}
</div>
</FocusScope>
</Dialog>
</RACPopover>
</DialogTrigger>
</div>
<ReactionPortals reactions={reactions} />
</>
@@ -6,8 +6,7 @@ import {
import React from 'react'
/** @public */
export interface AllowMediaPlaybackProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
export interface AllowMediaPlaybackProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label?: string
}
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const ToolsToggle = ({
variant = 'primaryTextDark',
@@ -15,6 +16,11 @@ export const ToolsToggle = ({
const { isToolsOpen, toggleTools } = useSidePanel()
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'recording',
handler: toggleTools,
})
return (
<div
className={css({
@@ -3,7 +3,6 @@ import { getScrollBarWidth } from '@livekit/components-core'
import * as React from 'react'
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { setCarouselLayout, resetLayout } from '@/stores/participantLayout'
const MIN_HEIGHT = 130
const MIN_WIDTH = 140
@@ -12,8 +11,7 @@ const ASPECT_RATIO = 16 / 10
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
/** @public */
export interface CarouselLayoutProps
extends React.HTMLAttributes<HTMLMediaElement> {
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
tracks: TrackReferenceOrPlaceholder[]
children: React.ReactNode
/** Place the tiles vertically or horizontally next to each other.
@@ -80,15 +78,6 @@ export function CarouselLayout({
}
}, [maxVisibleTiles, carouselOrientation])
// Update store when carousel layout is active
React.useEffect(() => {
setCarouselLayout()
return () => {
resetLayout()
}
}, [])
return (
<aside
key={carouselOrientation}
@@ -10,12 +10,11 @@ import { mergeProps } from '@/utils/mergeProps'
import { PaginationIndicator } from '../controls/PaginationIndicator'
import { useGridLayout } from '../../hooks/useGridLayout'
import { PaginationControl } from '../controls/PaginationControl'
import { setGridLayout, resetLayout } from '@/stores/participantLayout'
import { isTrackReference } from '@livekit/components-core'
/** @public */
export interface GridLayoutProps
extends React.HTMLAttributes<HTMLDivElement>,
extends
React.HTMLAttributes<HTMLDivElement>,
Pick<UseParticipantsOptions, 'updateOnlyOn'> {
children: React.ReactNode
tracks: TrackReferenceOrPlaceholder[]
@@ -37,11 +36,6 @@ export interface GridLayoutProps
* ```
* @public
*/
const getTrackId = (trackRef: TrackReferenceOrPlaceholder): string | null => {
if (!isTrackReference(trackRef)) return null
return `${trackRef.participant.sid}-${trackRef.source}`
}
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
const gridEl = React.createRef<HTMLDivElement>()
@@ -57,17 +51,6 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
onRightSwipe: pagination.prevPage,
})
// Update store when tracks change - set first tile ID for grid layout
React.useEffect(() => {
const firstTrack = pagination.tracks[0]
const firstTrackId = firstTrack ? getTrackId(firstTrack) : null
setGridLayout(firstTrackId)
return () => {
resetLayout()
}
}, [pagination.tracks])
return (
<div
ref={gridEl}
@@ -12,6 +12,6 @@ export function useCanPublishTrack(trackSource: TrackSource): boolean {
return Boolean(
permissions?.canPublish &&
permissions?.canPublishSources?.includes(trackSource)
permissions?.canPublishSources?.includes(trackSource)
)
}
@@ -1,19 +1,16 @@
import { useMemo } from 'react'
import { Shortcut } from '@/features/shortcuts/types'
import {
getShortcutDescriptorById,
ShortcutDescriptor,
} from '@/features/shortcuts/catalog'
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
return useMemo<Shortcut | undefined>(() => {
return useMemo<ShortcutDescriptor | undefined>(() => {
switch (kind) {
case 'audioinput':
return {
key: 'd',
ctrlKey: true,
}
return getShortcutDescriptorById('toggle-microphone')
case 'videoinput':
return {
key: 'e',
ctrlKey: true,
}
return getShortcutDescriptorById('toggle-camera')
default:
return undefined
}
@@ -18,8 +18,7 @@ import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>,
ChatOptions {}
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
/**
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
@@ -12,33 +12,20 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { openShortcutHelp } from '@/stores/shortcutHelp'
import { useFullScreen } from '../../hooks/useFullScreen'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
import { useSidePanel } from '../../hooks/useSidePanel'
import { useFullScreen } from '../../hooks/useFullScreen'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
export function DesktopControlBar({
onDeviceError,
}: Readonly<ControlBarAuxProps>) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
const { toggleParticipants, toggleChat, openScreenRecording } = useSidePanel()
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
const { openSettingsDialog } = useSettingsDialog()
useRegisterKeyboardShortcut({
shortcutId: 'open-shortcuts',
handler: () => {
openShortcutHelp()
},
})
// Keep legacy behavior: F2 focuses the first button in the bottom toolbar.
useRegisterKeyboardShortcut({
shortcutId: 'focus-toolbar',
id: 'focus-toolbar',
handler: () => {
const root = desktopControlBarEl.current
if (!root) return
@@ -50,32 +37,11 @@ export function DesktopControlBar({
})
useRegisterKeyboardShortcut({
shortcutId: 'toggle-participants',
handler: () => toggleParticipants(),
id: 'fullscreen',
handler: toggleFullScreen,
isDisabled: !isFullscreenAvailable,
})
useRegisterKeyboardShortcut({
shortcutId: 'toggle-chat',
handler: () => toggleChat(),
})
useRegisterKeyboardShortcut({
shortcutId: 'fullscreen',
handler: () => {
if (!isFullscreenAvailable) return
toggleFullScreen()
},
})
useRegisterKeyboardShortcut({
shortcutId: 'recording',
handler: () => openScreenRecording(),
})
useRegisterKeyboardShortcut({
shortcutId: 'open-shortcuts-settings',
handler: () => openSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS),
})
return (
<div
ref={desktopControlBarEl}
@@ -31,6 +31,9 @@ import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useSettingsDialog } from '@/features/settings'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
@@ -66,8 +69,7 @@ const LayoutWrapper = styled(
/**
* @public
*/
export interface VideoConferenceProps
extends React.HTMLAttributes<HTMLDivElement> {
export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElement> {
/** @alpha */
SettingsComponent?: React.ComponentType
}
@@ -98,6 +100,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const { t: tRooms } = useTranslation('rooms')
const room = useRoomContext()
const announce = useScreenReaderAnnounce()
const { toggleSettingsDialog } = useSettingsDialog()
const getAnnouncementName = useCallback(
(participant?: Participant | null) => {
@@ -112,6 +115,13 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
useConnectionObserver()
useVideoResolutionSubscription()
useRegisterKeyboardShortcut({
id: 'open-shortcuts',
handler: useCallback(() => {
toggleSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS)
}, [toggleSettingsDialog]),
})
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
@@ -9,8 +9,7 @@ import NoiseSuppressorWorklet from '@timephy/rnnoise-wasm/NoiseSuppressorWorklet
// and suspend/resume it as needed to manage audio state
let audioContext: AudioContext
export interface AudioProcessorInterface
extends TrackProcessor<Track.Kind.Audio> {
export interface AudioProcessorInterface extends TrackProcessor<Track.Kind.Audio> {
name: string
}
@@ -4,8 +4,10 @@ export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
export const flexibleRoomIdPattern =
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
const roomRegex = new RegExp(`^${roomIdPattern}$`)
export const isRoomValid = (roomIdOrUrl: string) =>
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
roomRegex.test(roomIdOrUrl) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
export const normalizeRoomId = (roomId: string) => {
@@ -10,7 +10,10 @@ const callbackIdHandler = new CallbackIdHandler()
const popupWindow = new PopupWindow()
export const CreatePopup = () => {
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
const { isLoggedIn, user } = useUser({
fetchUserOptions: { attemptSilent: false },
})
const canCreate = user?.can_create === true
const { mutateAsync: createRoom } = useCreateRoom()
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
@@ -55,10 +58,10 @@ export const CreatePopup = () => {
console.error('Failed to create meeting room:', error)
}
}
if (isLoggedIn && callbackId) {
if (isLoggedIn && canCreate && callbackId) {
createMeetingRoom()
}
}, [isLoggedIn, callbackId, createRoom])
}, [isLoggedIn, canCreate, callbackId, createRoom])
return (
<div
@@ -7,12 +7,12 @@ import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import {
RiAccountCircleLine,
RiKeyboardBoxLine,
RiNotification3Line,
RiSettings3Line,
RiSpeakerLine,
RiVideoOnLine,
RiEyeLine,
RiKeyboardBoxLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
@@ -20,12 +20,12 @@ import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab'
import { VideoTab } from './tabs/VideoTab'
import { TranscriptionTab } from './tabs/TranscriptionTab'
import { ShortcutTab } from './tabs/ShortcutTab'
import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import ShortcutTab from './tabs/ShortcutTab'
import AccessibilityTab from './tabs/AccessibilityTab'
import { AccessibilityTab } from './tabs/AccessibilityTab'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -41,6 +41,7 @@ const tabListContainerStyle = css({
flexDirection: 'column',
borderRight: '1px solid lightGray', // fixme poor color management
paddingY: '1rem',
paddingLeft: '0.2rem',
paddingRight: '1.5rem',
})
@@ -36,5 +36,3 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
</TabPanel>
)
}
export default AccessibilityTab
@@ -1,196 +0,0 @@
/**
* Edit and reset feature, not used yet
*
* This component handles edit and reset actions for keyboard shortcuts.
* To use it, uncomment the import and usage in ShortcutTab.tsx
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { Shortcut } from '@/features/shortcuts/types'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { buttonRecipe } from '@/primitives/buttonRecipe'
import { removeOverride, setOverride } from '@/stores/shortcutOverrides'
import { isMacintosh } from '@/utils/livekit'
const buttonLink = buttonRecipe({ variant: 'secondary', size: 'sm' })
export interface ShortcutEditActionsProps {
shortcutId: string
actionLabel: string
srShortcut: string
hasOverride: boolean
onConfirmationChange?: (message: string) => void
}
export const ShortcutEditActions = ({
shortcutId,
actionLabel,
srShortcut,
hasOverride,
onConfirmationChange,
}: ShortcutEditActionsProps) => {
const { t } = useTranslation(['settings'])
const [editingId, setEditingId] = useState<string | null>(null)
const timeoutRef = useRef<number | null>(null)
const handleStartEdit = useCallback(() => {
setEditingId(shortcutId)
}, [shortcutId])
const handleCancelEdit = useCallback(() => {
setEditingId(null)
}, [])
const handleEditButtonClick = useCallback(() => {
// If already in edit mode, cancel it
if (editingId === shortcutId) {
handleCancelEdit()
return
}
// Otherwise, start edit mode
handleStartEdit()
}, [editingId, shortcutId, handleCancelEdit, handleStartEdit])
const handleReset = useCallback(() => {
removeOverride(shortcutId)
const message = t('shortcutsEditor.resetConfirmation', {
defaultValue: 'Shortcut reset',
})
onConfirmationChange?.(message)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = window.setTimeout(() => {
onConfirmationChange?.('')
timeoutRef.current = null
}, 3000)
}, [shortcutId, t, onConfirmationChange])
const handleKeyCapture = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>) => {
e.preventDefault()
const { key, ctrlKey, shiftKey, altKey, metaKey } = e
// Ignore modifier-only keys
if (
!key ||
key === 'Control' ||
key === 'Meta' ||
key === 'Shift' ||
key === 'Alt' ||
key === 'Tab' ||
key === 'Escape'
)
return
const normalized: Shortcut = {
key,
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
shiftKey,
altKey,
}
setOverride(shortcutId, normalized)
setEditingId(null)
const message = t('shortcutsEditor.modifiedConfirmation', {
defaultValue: 'Shortcut modified',
})
onConfirmationChange?.(message)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = window.setTimeout(() => {
onConfirmationChange?.('')
timeoutRef.current = null
}, 3000)
},
[shortcutId, t, onConfirmationChange]
)
const handleEditButtonKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>) => {
// If already in edit mode, capture the key
if (editingId === shortcutId) {
handleKeyCapture(e)
return
}
// Otherwise, if it's Enter or Space, start edit mode (like a click)
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleStartEdit()
}
},
[editingId, shortcutId, handleKeyCapture, handleStartEdit]
)
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [])
const editButtonLabel =
editingId === shortcutId
? t('shortcutsEditor.capture')
: t('shortcutsEditor.edit')
const editButtonAriaLabel =
editingId === shortcutId
? t('shortcutsEditor.captureAria', {
defaultValue: 'Press keys to set shortcut for {{action}}',
action: actionLabel,
})
: t('shortcutsEditor.editAria', {
defaultValue: 'Edit shortcut for {{action}}',
action: actionLabel,
})
const resetButtonAriaLabel = t('shortcutsEditor.resetAria', {
defaultValue: 'Reset shortcut for {{action}}',
action: actionLabel,
})
return (
<div
role="group"
aria-label={t('shortcutsEditor.actionsGroupAria', {
defaultValue: 'Actions for {{action}}',
action: actionLabel,
})}
className={css({
display: 'flex',
gap: '0.35rem',
justifyContent: 'flex-end',
})}
>
<button
type="button"
className={buttonLink}
onKeyDown={handleEditButtonKeyDown}
onClick={handleEditButtonClick}
aria-pressed={editingId === shortcutId}
aria-label={editButtonAriaLabel}
aria-describedby={`shortcut-${shortcutId}-description`}
>
{editButtonLabel}
</button>
<button
type="button"
className={buttonLink}
onClick={handleReset}
aria-disabled={!hasOverride}
disabled={!hasOverride}
aria-label={resetButtonAriaLabel}
aria-describedby={`shortcut-${shortcutId}-description`}
style={{ opacity: !hasOverride ? 0.5 : 1 }}
>
{t('shortcutsEditor.reset')}
</button>
<span id={`shortcut-${shortcutId}-description`} className="sr-only">
{t('shortcutsEditor.currentShortcut', {
defaultValue: 'Current shortcut: {{shortcut}}',
shortcut: srShortcut,
})}
</span>
</div>
)
}
@@ -1,39 +1,30 @@
import { useCallback, useEffect, useMemo } from 'react'
import { shortcutCatalog } from '@/features/shortcuts/catalog'
import { ShortcutRow } from '@/features/shortcuts/components'
import { ShortcutRow } from '@/features/shortcuts/components/ShortcutRow'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { text } from '@/primitives/Text'
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
import { useSnapshot } from 'valtio'
import {
loadShortcutOverrides,
shortcutOverridesStore,
} from '@/stores/shortcutOverrides'
const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
const tableStyle = css({
width: '100%',
borderCollapse: 'collapse',
overflowY: 'auto',
'& caption': {
fontWeight: 'bold',
marginBottom: '0.75rem',
textAlign: 'left',
},
'& th, & td': {
padding: '0.65rem 0',
textAlign: 'left',
fontWeight: 'normal',
},
'& tbody tr': {
borderBottom: '1px solid rgba(255,255,255,0.08)',
},
})
export const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
const { t } = useTranslation(['settings', 'rooms'])
const tRooms = useCallback(
(key: string, options?: Record<string, unknown>) =>
t(key, { ns: 'rooms', ...options }),
[t]
)
useEffect(() => {
loadShortcutOverrides()
}, [])
const { overrides } = useSnapshot(shortcutOverridesStore)
const rows = useMemo(() => {
return shortcutCatalog.map((item) => {
const override = overrides.get(item.id)
const effectiveShortcut = override ?? item.shortcut
return {
item,
override,
effectiveShortcut,
}
})
}, [overrides])
return (
<TabPanel
@@ -43,44 +34,22 @@ const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<div className={text({ variant: 'h2' })}>{t('tabs.shortcuts')}</div>
<div
role="list"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
aria-label={t('shortcutsEditor.listLabel', {
defaultValue: 'List of keyboard shortcuts',
})}
className={css({
display: 'grid',
gap: '0.25rem',
maxHeight: '420px',
overflowY: 'auto',
paddingRight: '0.35rem',
outline: 'none',
'&:focus-visible': {
outline: '2px solid rgba(255,255,255,0.5)',
outlineOffset: '2px',
borderRadius: '6px',
},
})}
>
{rows.map(({ item, override, effectiveShortcut }) => (
<ShortcutRow
key={item.id}
descriptor={item}
effectiveShortcut={effectiveShortcut}
override={override}
actionLabel={tRooms(`shortcutsPanel.actions.${item.id}`)}
customLabel={t('shortcutsEditor.custom')}
/>
))}
</div>
<table className={tableStyle}>
<caption>{t('shortcuts.listLabel')}</caption>
<thead>
<tr>
<th scope="col">{t('shortcuts.columnAction')}</th>
<th scope="col">{t('shortcuts.columnShortcut')}</th>
</tr>
</thead>
<tbody>
{shortcutCatalog.map((item) => (
<ShortcutRow key={item?.id} descriptor={item} />
))}
</tbody>
</table>
</TabPanel>
)
}
export default ShortcutTab
@@ -4,7 +4,7 @@ import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { css } from '@/styled-system/css'
import {
createLocalVideoTrack,
@@ -22,6 +22,8 @@ export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
type DeviceItems = Array<{ value: string; label: string }>
const EMPTY_PROPS = {}
export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' })
const { localParticipant, remoteParticipants } = useRoomContext()
@@ -59,7 +61,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
const isCamEnabled = devicesIn?.length > 0
const disabledProps = isCamEnabled
? {}
? EMPTY_PROPS
: {
placeholder: t('permissionsRequired'),
isDisabled: true,
@@ -117,6 +119,40 @@ export const VideoTab = ({ id }: VideoTabProps) => {
}
}, [videoDeviceId, videoElement])
const resolutionItems = useMemo(() => {
return [
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]
}, [t])
const videoQualityItems = useMemo(() => {
return [
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]
}, [t])
return (
<TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('camera.heading')}>
@@ -178,20 +214,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
<Field
type="select"
label={t('resolution.publish.label')}
items={[
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]}
items={resolutionItems}
selectedKey={videoPublishResolution}
onSelectionChange={async (key) => {
await handleVideoResolutionChange(key as VideoResolution)
@@ -206,20 +229,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
<Field
type="select"
label={t('resolution.subscribe.label')}
items={[
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]}
items={videoQualityItems}
selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => {
if (key == undefined) return
@@ -1,31 +0,0 @@
import { useMemo, useState } from 'react'
import { text } from '@/primitives/Text'
/**
* Hook to manage confirmation messages for edit/reset actions
* Use in the parent component to display confirmation messages
*/
export const useShortcutConfirmation = () => {
const [confirmationMessage, setConfirmationMessage] = useState<string>('')
const confirmationElement = useMemo(() => {
if (!confirmationMessage) return null
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className={text({ variant: 'smNote' })}
>
{confirmationMessage}
</div>
)
}, [confirmationMessage])
return {
confirmationMessage,
setConfirmationMessage,
ConfirmationMessage: confirmationElement,
}
}
@@ -14,7 +14,25 @@ export const useSettingsDialog = () => {
settingsStore.areSettingsOpen = true
}
const closeSettingsDialog = () => {
settingsStore.areSettingsOpen = false
}
const toggleSettingsDialog = (
defaultSelectedTab?: SettingsDialogExtendedKey
) => {
if (areSettingsOpen) {
closeSettingsDialog()
} else {
if (defaultSelectedTab)
settingsStore.defaultSelectedTab = defaultSelectedTab
settingsStore.areSettingsOpen = true
}
}
return {
openSettingsDialog,
closeSettingsDialog,
toggleSettingsDialog,
}
}
+7 -14
View File
@@ -1,8 +1,7 @@
import { Shortcut } from './types'
// Central list of current keyboard shortcuts. This will feed the future
// tooltip/panel so there is a single source of truth for display and, later,
// customization.
// Central list of current keyboard shortcuts.
// Keep a single source of truth for display and, later, customization
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId =
@@ -11,15 +10,14 @@ export type ShortcutId =
| 'toggle-microphone'
| 'toggle-camera'
| 'push-to-talk'
| 'reaction'
| 'fullscreen'
| 'recording'
| 'raise-hand'
| 'toggle-chat'
| 'toggle-participants'
| 'open-shortcuts-settings'
| 'raise-hand'
| 'recording'
| 'reaction'
| 'fullscreen'
export const getShortcutById = (id: ShortcutId) =>
export const getShortcutDescriptorById = (id: ShortcutId) =>
shortcutCatalog.find((item) => item.id === id)
export type ShortcutDescriptor = {
@@ -88,9 +86,4 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
category: 'interaction',
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
},
{
id: 'open-shortcuts-settings',
category: 'navigation',
shortcut: { key: 'K', ctrlKey: true, altKey: true },
},
]

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