Compare commits

..

41 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
136 changed files with 3465 additions and 19380 deletions
+33 -33
View File
@@ -43,12 +43,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -86,12 +86,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -130,12 +130,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -174,13 +174,13 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/summary/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
@@ -220,14 +220,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/agents/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
# docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
+29
View File
@@ -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 }}
+31 -1
View File
@@ -8,9 +8,31 @@ 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
@@ -21,8 +43,16 @@ and this project adheres to
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
- 🌐(frontend) localize SR modifier labels #1010
- ⬆️(backend) update python dependencies #1011
- ♿️(a11y) fix focus ring on tab container components
- ♿️(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
+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
-18
View File
@@ -38,20 +38,6 @@ COPY ./docker/dinum-frontend/assets/ \
COPY ./docker/dinum-frontend/fonts/ \
./dist/assets/fonts/
# ---- Outlook add-in builder image ----
FROM node:20-alpine AS outlook-addin-builder
WORKDIR /home/outlook-addin
COPY ./src/addins/outlook-addin/package.json ./package.json
COPY ./src/addins/outlook-addin/package-lock.json ./package-lock.json
RUN npm ci
COPY ./src/addins/outlook-addin/ .
RUN npx webpack --mode production
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
@@ -73,10 +59,6 @@ COPY --from=meet-builder \
/home/frontend/dist \
/usr/share/nginx/html
COPY --from=outlook-addin-builder \
/home/outlook-addin/dist \
/usr/share/nginx/html/outlook-addin
COPY ./src/frontend/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
+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
-8
View File
@@ -1,8 +0,0 @@
{
"plugins": [
"office-addins"
],
"extends": [
"plugin:office-addins/recommended"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,12 +0,0 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"esmodules": false
}
}
],
]
}
-173
View File
@@ -1,173 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
<Version>0.0.1.0</Version>
<ProviderName>Visio</ProviderName>
<DefaultLocale>en-US</DefaultLocale>
<DisplayName DefaultValue="Visio"/>
<Description DefaultValue="Ajoutez facilement un lien de réunion Visio à vos emails et événements Outlook."/>
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png"/>
<SupportUrl DefaultValue="https://www.contoso.com/help"/>
<AppDomains>
<AppDomain>https://localhost:3000</AppDomain>
<AppDomain>https://meet.127.0.0.1.nip.io</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Mailbox"/>
</Hosts>
<Requirements>
<Sets>
<Set Name="Mailbox" MinVersion="1.1"/>
</Sets>
</Requirements>
<FormSettings>
<Form xsi:type="ItemRead">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
<RequestedHeight>250</RequestedHeight>
</DesktopSettings>
</Form>
<Form xsi:type="ItemEdit">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
</DesktopSettings>
</Form>
</FormSettings>
<Permissions>ReadWriteItem</Permissions>
<Rule xsi:type="RuleCollection" Mode="Or">
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit"/>
</Rule>
<DisableEntityHighlighting>false</DisableEntityHighlighting>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
<Requirements>
<bt:Sets DefaultMinVersion="1.3">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="Commands.Url"/>
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgReadGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgReadOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
<OfficeTab id="TabDefault">
<Group id="apptComposeGroup">
<Label resid="GroupLabel"/>
<!-- Button 1: Generate meeting link (function call) -->
<Control xsi:type="Button" id="apptGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
</Action>
</Control>
<!-- Button 2: Open settings taskpane -->
<Control xsi:type="Button" id="apptOpenSettingsButton">
<Label resid="OpenSettings.Label"/>
<Supertip>
<Title resid="OpenSettings.Label"/>
<Description resid="OpenSettings.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<bt:String id="GroupLabel" DefaultValue="Visio"/>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir le panneau"/>
<bt:String id="GenerateLink.Label" DefaultValue="Générer un lien de réunion"/>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre le panneau de connexion Visio."/>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion Visio et l'insère dans l'événement."/>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion Visio."/>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</OfficeApp>
File diff suppressed because it is too large Load Diff
-63
View File
@@ -1,63 +0,0 @@
{
"name": "office-addin-taskpane-js",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "https://github.com/OfficeDev/Office-Addin-TaskPane-JS.git"
},
"license": "MIT",
"config": {
"app_to_debug": "outlook",
"app_type_to_debug": "desktop",
"dev_server_port": 3000
},
"scripts": {
"build": "webpack --mode production",
"build:dev": "webpack --mode development",
"dev-server": "webpack serve --mode development",
"lint": "office-addin-lint check",
"lint:fix": "office-addin-lint fix",
"prettier": "office-addin-lint prettier",
"signin": "office-addin-dev-settings m365-account login",
"signout": "office-addin-dev-settings m365-account logout",
"start": "office-addin-debugging start manifest.xml",
"stop": "office-addin-debugging stop manifest.xml",
"validate": "office-addin-manifest validate manifest.xml",
"watch": "webpack --mode development --watch"
},
"dependencies": {
"core-js": "^3.36.0",
"regenerator-runtime": "^0.14.1"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.25.4",
"@types/office-js": "^1.0.377",
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
"html-webpack-inject-attributes-plugin": "^1.0.6",
"html-webpack-plugin": "^5.6.0",
"office-addin-cli": "^2.0.3",
"office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "^3.0.3",
"office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "^2.0.1",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.1.0"
},
"prettier": "office-addin-prettier-config",
"browserslist": [
"last 2 versions",
"ie 11"
]
}
@@ -1,9 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body></body>
</html>
@@ -1,80 +0,0 @@
/* global Office */
const { loadSession, buildMeetingMessage, BASE_URL } = require("../common");
Office.onReady(() => {});
function generateMeetingLinkFromCalendar(event) {
const session = loadSession();
if (!session?.access_token) {
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: "Vous n'êtes pas connecté. Ouvrez les paramètres pour vous connecter.",
});
event.completed();
return;
}
fetch(`${BASE_URL}/external-api/v1.0/rooms/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + session.access_token,
},
})
.then((res) => res.json())
.then((data) => {
console.log("Room created:", data);
const { url, message } = buildMeetingMessage(data);
const item = Office.context.mailbox.item;
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: `Erreur de lecture: ${getResult.error.message}`,
});
event.completed();
return;
}
item.body.setAsync(getResult.value + message, { coercionType: Office.CoercionType.Html }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: `Erreur d'insertion: ${setResult.error.message}`,
});
event.completed();
return;
}
item.location.setAsync(url, (locationResult) => {
if (locationResult.status === Office.AsyncResultStatus.Succeeded) {
item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
message: "Lien de réunion inséré !",
icon: "Icon.80x80",
persistent: false,
});
} else {
item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: `Erreur de localisation: ${locationResult.error.message}`,
});
}
event.completed();
});
});
});
})
.catch((err) => {
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: `Erreur: ${err.message}`,
});
event.completed();
});
}
Office.actions.associate("generateMeetingLinkFromCalendar", generateMeetingLinkFromCalendar);
-99
View File
@@ -1,99 +0,0 @@
/* global Office */
const BASE_URL = "https://meet.127.0.0.1.nip.io"; // todo - use env variable
// ─── Session Storage ──────────────────────────────────────────────────────
function saveSession(data) {
const expiresAt = data.expires_in
? new Date(Date.now() + data.expires_in * 1000).toISOString()
: null;
const payload = JSON.stringify({
...data,
expiresAt,
savedAt: new Date().toISOString(),
});
localStorage.setItem("meetSession", payload);
const rs = Office.context.roamingSettings;
rs.set("meetSession", payload);
rs.saveAsync((result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
console.error("RoamingSettings save failed:", result.error.message);
}
});
}
function loadSession() {
let session = null;
try {
const stored = Office.context.roamingSettings.get("meetSession");
if (stored) session = JSON.parse(stored);
} catch (e) {
console.warn("RoamingSettings read failed:", e);
}
if (!session) {
try {
const stored = localStorage.getItem("meetSession");
if (stored) session = JSON.parse(stored);
} catch (e) {
console.warn("localStorage read failed:", e);
}
}
if (!session) return null;
if (session.expiresAt && new Date() > new Date(session.expiresAt)) {
console.warn("Token expired, clearing session.");
clearSession();
return null;
}
return session;
}
function clearSession() {
localStorage.removeItem("meetSession");
try {
const rs = Office.context.roamingSettings;
rs.remove("meetSession");
rs.saveAsync(() => console.log("RoamingSettings cleared."));
} catch (e) {
console.warn("Could not clear RoamingSettings:", e);
}
}
// ─── Meeting Message Builder ───────────────────────────────────────────────
function buildMeetingMessage(data) {
const url = data.url;
const phone = data.telephony?.phone_number;
const pin = data.telephony?.pin_code;
const formattedPin = pin
? pin.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3") + "#"
: "";
const formattedPhone = phone
? phone.replace(/^\+33(\d)(\d{2})(\d{2})(\d{2})(\d{2})$/, "+33 $1 $2 $3 $4 $5")
: phone;
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
────────────────────────────────────────
Rejoindre la réunion LaSuite Meet
<a href="${url}">${url}</a>
Ou appelez (audio uniquement)
(FR) ${formattedPhone}
Code : ${formattedPin}
────────────────────────────────────────</pre>`;
return { url, message };
}
module.exports = { BASE_URL, saveSession, loadSession, clearSession, buildMeetingMessage };
File diff suppressed because one or more lines are too long
@@ -1,58 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Visio</title>
<link rel="stylesheet" href="taskpane.css" />
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div id="app-body">
<!-- Loading -->
<div id="view-loading">
<p class="intro-text">Chargement...</p>
</div>
<!-- Unauthenticated -->
<div id="view-unauth" style="display:none;">
<p class="intro-text">
<span>Ajoutez facilement un lien de réunion Visio à vos événements Outlook.</span>
<a href="https://meet.numerique.gouv.fr" target="_blank" class="learn-more">En savoir plus</a>
</p>
<hr class="divider" />
<button class="proconnect-button" id="btn-connect">
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
</button>
<p>
<a
href="https://www.proconnect.gouv.fr/"
target="_blank"
rel="noopener noreferrer"
title="Quest-ce que ProConnect ? - nouvelle fenêtre"
>
Quest-ce que ProConnect ?
</a>
</p>
</div>
<!-- Authenticated -->
<div id="view-auth" style="display:none;">
<div id="btn-container">
<button id="btn-generate">Ajouter une réunion Visio</button>
<button id="btn-disconnect">Se déconnecter</button>
</div>
</div>
<p id="status"></p>
</div>
</body>
</html>
@@ -1,223 +0,0 @@
const { BASE_URL, loadSession, saveSession, clearSession, buildMeetingMessage } = require("../common");
// ─── Views ────────────────────────────────────────────────────────────────
function showView(name) {
document.getElementById("view-loading").style.display = "none";
document.getElementById("view-unauth").style.display = "none";
document.getElementById("view-auth").style.display = "none";
document.getElementById(`view-${name}`).style.display = "block";
}
function setStatus(msg) {
document.getElementById("status").textContent = msg;
}
// ─── Polling ──────────────────────────────────────────────────────────────
function startPolling(session_id, { onSuccess, onTimeout, onError }) {
let pollCount = 0;
const pollInterval = setInterval(() => {
// ─── Timeout after 3 minutes ──────────────────────────────
if (pollCount++ > 180) {
clearInterval(pollInterval);
onTimeout?.();
return;
}
fetch(`${BASE_URL}/api/v1.0/addons/sessions/wip/`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id }),
})
.then((res) => res.json())
.then((sessionData) => {
console.log("Polling:", sessionData);
if (sessionData.state === "authenticated" && sessionData.access_token) {
clearInterval(pollInterval);
onSuccess?.(sessionData);
}
})
.catch((err) => {
clearInterval(pollInterval);
onError?.(err);
});
}, 1000);
return pollInterval;
}
// ─── Transit Dialog ───────────────────────────────────────────────────────
function openTransitDialog(transit_token, { onCancel, onError }) {
const meetUrl = `${BASE_URL}/addons/transit/?transit_token=${transit_token}`;
Office.context.ui.displayDialogAsync(
meetUrl,
{ height: 60, width: 50, displayInIframe: false },
(asyncResult) => {
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
onError?.(asyncResult.error);
return;
}
const dialog = asyncResult.value;
dialog.addEventHandler(Office.EventType.DialogMessageReceived, () => {
onCancel?.();
dialog.close();
});
dialog.addEventHandler(Office.EventType.DialogEventReceived, (arg) => {
if (arg.error === 12006) {
setStatus("Dialog fermé. En attente d'authentification...");
}
});
return dialog;
}
);
}
// ─── Auth Flow ────────────────────────────────────────────────────────────
function connect() {
setStatus("Démarrage de la session...");
fetch(`${BASE_URL}/api/v1.0/addons/sessions/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
})
.then((res) => res.json())
.then((data) => {
const session_id = data.session_id;
const transit_token = data.transit_token;
setStatus("En attente d'authentification...");
const pollInterval = startPolling(session_id, {
onSuccess: (sessionData) => {
saveSession(sessionData);
setStatus("Connecté !");
showView("auth");
},
onTimeout: () => {
setStatus("Délai d'authentification dépassé. Veuillez réessayer.");
showView("unauth");
},
onError: (err) => {
setStatus(`Erreur de polling: ${err.message}`);
},
});
openTransitDialog(transit_token, {
onCancel: () => clearInterval(pollInterval),
onError: (err) => {
clearInterval(pollInterval);
setStatus(`Erreur dialog: ${err.message}`);
},
});
})
.catch((err) => {
setStatus(`Erreur de connexion: ${err.message}`);
});
}
function disconnect() {
clearSession();
setStatus("Déconnecté.");
showView("unauth");
}
function generateMeetingLink() {
const session = loadSession();
if (!session?.access_token) {
setStatus("Session introuvable. Veuillez vous reconnecter.");
showView("unauth");
return;
}
const btn = document.getElementById("btn-generate");
btn.disabled = true;
btn.textContent = "Génération...";
fetch(`${BASE_URL}/external-api/v1.0/rooms/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + session.access_token,
},
})
.then((res) => res.json())
.then((data) => {
console.log("Room created:", data);
const { url, message } = buildMeetingMessage(data);
const item = Office.context.mailbox.item;
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
setStatus(`Erreur de lecture: ${getResult.error.message}`);
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
return;
}
item.body.setAsync(
getResult.value + message,
{ coercionType: Office.CoercionType.Html },
(setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
setStatus(`Erreur d'insertion: ${setResult.error.message}`);
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
return;
}
// ─── If calendar event, also set location ──────────────
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
item.location.setAsync(url, (locationResult) => {
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
if (locationResult.status === Office.AsyncResultStatus.Succeeded) {
setStatus("Lien de réunion inséré !");
} else {
setStatus(`Erreur de localisation: ${locationResult.error.message}`);
}
});
} else {
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
setStatus("Lien de réunion inséré !");
}
}
);
});
})
.catch((err) => {
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
setStatus(`Erreur: ${err.message}`);
});
}
// ─── Init ─────────────────────────────────────────────────────────────────
Office.onReady((info) => {
if (info.host === Office.HostType.Outlook) {
document.getElementById("sideload-msg").style.display = "none";
document.getElementById("app-body").style.display = "flex";
document.getElementById("btn-connect").onclick = connect;
document.getElementById("btn-disconnect").onclick = disconnect;
document.getElementById("btn-generate").onclick = generateMeetingLink;
const session = loadSession();
if (session?.state === "authenticated" && session?.access_token) {
setStatus("Connecté.");
showView("auth");
} else {
showView("unauth");
}
}
});
-110
View File
@@ -1,110 +0,0 @@
/* eslint-disable no-undef */
const devCerts = require("office-addin-dev-certs");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
const urlDev = "https://localhost:3000/";
const urlProd = "https://meet.127.0.0.1.nip.io/outlook-addin/";
async function getHttpsOptions() {
const httpsOptions = await devCerts.getHttpsServerOptions();
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
}
module.exports = async (env, options) => {
const dev = options.mode === "development";
const config = {
devtool: "source-map",
entry: {
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
commands: "./src/commands/commands.js",
},
output: {
clean: true,
},
resolve: {
extensions: [".html", ".js"],
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.html$/,
exclude: /node_modules/,
use: "html-loader",
},
{
test: /\.(png|jpg|jpeg|gif|ico)$/,
type: "asset/resource",
generator: {
filename: "assets/[name][ext][query]",
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
filename: "taskpane.html",
template: "./src/taskpane/taskpane.html",
chunks: ["polyfill", "taskpane"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new CopyWebpackPlugin({
patterns: [
{
from: "assets/*",
to: "assets/[name][ext][query]",
},
{
from: "manifest*.xml",
to: "[name]" + "[ext]",
transform(content) {
if (dev) {
return content;
} else {
return content.toString().replace(new RegExp(urlDev, "g"), urlProd);
}
},
},
],
}),
new HtmlWebpackPlugin({
filename: "commands.html",
template: "./src/commands/commands.html",
chunks: ["polyfill", "commands"],
scriptLoading: "defer",
attributes: {
nonce: "NONCE_PLACEHOLDER",
},
}),
new htmlWebpackInjectAttributesPlugin(),
],
devServer: {
headers: {
"Access-Control-Allow-Origin": "*",
},
server: {
type: "https",
options:
env.WEBPACK_BUILD || options.https !== undefined
? options.https
: await getHttpsOptions(),
},
port: process.env.npm_package_config_dev_server_port || 3000,
},
};
return config;
};
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.8.0"
version = "1.9.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
-1
View File
@@ -1 +0,0 @@
"""Meet core add-ons module."""
-192
View File
@@ -1,192 +0,0 @@
"""Authentication session management for add-ons using temporary cache-based sessions."""
import secrets
from datetime import datetime, timedelta, timezone
from enum import Enum
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import SuspiciousOperation
from core.models import User
from core.services.jwt_token import JwtTokenService
logger = getLogger(__name__)
class SessionOwnershipError(Exception):
"""Raised when the claimed session_id does not match the result_token binding."""
class SessionState(str, Enum):
"""Add-on authentication session states."""
PENDING = "pending"
AUTHENTICATED = "authenticated"
class TokenExchangeService:
"""Manage temporary authentication sessions for add-on JWT token exchange."""
def __init__(self):
"""Initialize the service with the configured token service."""
self._token_service = JwtTokenService(
secret_key=settings.ADDONS_JWT_SECRET_KEY,
algorithm=settings.ADDONS_JWT_ALG,
issuer=settings.ADDONS_JWT_ISSUER,
audience=settings.ADDONS_JWT_AUDIENCE, # todo - precise
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
)
def _session_cache_key(self, session_id: str) -> str:
"""Generate cache key for a session ID."""
return f"{settings.ADDONS_SESSION_KEY_PREFIX}_{session_id}"
def _token_cache_key(self, result_token: str) -> str:
"""Wip."""
return f"{settings.ADDONS_SESSION_TOKEN_PREFIX}_{result_token}"
def init_session(self) -> tuple[str, str, str]:
"""Create a new pending authentication session and return its ID."""
session_id = secrets.token_urlsafe(settings.ADDONS_SESSION_ID_LENGTH)
result_token = secrets.token_urlsafe(32) # separate, never in any UR
expires_at = datetime.now(timezone.utc) + timedelta(
seconds=settings.ADDONS_SESSION_TIMEOUT
)
session_data = {
"state": SessionState.PENDING,
"expires_at": expires_at.isoformat(),
}
# Store the session itself
cache.set(
self._session_cache_key(session_id),
session_data,
timeout=settings.ADDONS_SESSION_TIMEOUT,
)
# Store the token → session_id binding (same TTL)
cache.set(
self._token_cache_key(result_token),
session_id,
timeout=settings.ADDONS_SESSION_TIMEOUT,
)
# Transit token → session_id, very short TTL, one-time use
transit_token = secrets.token_urlsafe(32)
cache.set(
f"addon_transit_{transit_token}",
session_id,
timeout=120
)
print('$$ init transit_token')
print(transit_token)
return session_id, result_token, transit_token
# todo - wip
def get_session(self, session_id: str) -> dict:
"""Retrieve session data and clear it if authenticated."""
return self._get_and_maybe_clear(session_id)
def get_session_by_token(self, result_token: str, claimed_session_id: str) -> dict:
"""Resolve result_token → session_id → session data.
Verifies that the claimed_session_id matches the token binding,
proving the caller initiated this session (ownership check).
Clears the session once authenticated (one-time read).
"""
session_id = cache.get(self._token_cache_key(result_token))
if not session_id:
return {}
print("$$$ session_id")
print(session_id)
print("$$$ claimed_session_id")
print(claimed_session_id)
if not secrets.compare_digest(session_id, claimed_session_id):
raise SessionOwnershipError("Session ID does not match token binding.")
return self._get_and_maybe_clear(session_id)
def _get_and_maybe_clear(self, session_id: str) -> dict:
"""Wip."""
cache_key = self._session_cache_key(session_id)
data = cache.get(cache_key)
if not data:
return {}
if data.get("state") == SessionState.AUTHENTICATED:
# One-time read: clear both the session and the token binding
self.clear_session(session_id)
# Return copy without internal fields
internal_fields = {"expires_at"}
return {k: v for k, v in data.items() if k not in internal_fields}
def clear_session(self, session_id: str, result_token: str | None = None) -> None:
"""Wip."""
cache.delete(self._session_cache_key(session_id))
if result_token:
cache.delete(self._token_cache_key(result_token))
def set_access_token(self, user: User, session_id: str):
"""Generate and store access token for an authenticated user session."""
cache_key = self._session_cache_key(session_id)
existing_data = cache.get(cache_key)
if not existing_data:
raise SuspiciousOperation("Session not found.")
expires_at = existing_data.get("expires_at", None)
if not expires_at:
self.clear_session(session_id)
raise SuspiciousOperation("Invalid session data.")
remaining_seconds = int(
(
datetime.fromisoformat(expires_at) - datetime.now(timezone.utc)
).total_seconds()
)
if remaining_seconds <= 0:
self.clear_session(session_id)
raise SuspiciousOperation("Session expired.")
if existing_data.get("state") != SessionState.PENDING:
self.clear_session(session_id)
raise SuspiciousOperation("Access token already set.")
response = self._token_service.generate_jwt(user, settings.ADDONS_SCOPES)
new_data = {
**existing_data,
**response,
"state": SessionState.AUTHENTICATED,
}
cache.set(cache_key, new_data, timeout=remaining_seconds)
def token_to_session(self, result_token):
"""wip."""
return None
def consume_transit_token(self, transit_token: str) -> str | None:
"""Resolve and immediately delete the transit token (one-time use)."""
key = f"addon_transit_{transit_token}"
session_id = cache.get(key)
if session_id:
cache.delete(key) # consumed — cannot be replayed
return session_id
-62
View File
@@ -1,62 +0,0 @@
"""Add-ons views."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from core.addons.service import SessionState, TokenExchangeService
def render_error(request, message, status=400):
"""Render simple error page."""
return render(request, "addons/error.html", {"message": message}, status=status)
@require_http_methods(["GET"])
def transit_page(request):
"""Initialize authentication flow for add-on session."""
transit_token = request.GET.get("transit_token")
if not transit_token:
return render_error(request, _("Transit token is required."), status=400)
session_id = TokenExchangeService().consume_transit_token(transit_token)
if not session_id:
return render_error(request, _("Invalid or expired transit token."), status=404)
# Validate the session is still pending
data = TokenExchangeService().get_session(session_id)
if not data:
return render_error(request, _("Session not found or expired."), status=404)
if data.get("state") != SessionState.PENDING:
return render_error(request, _("Invalid session state."), status=400)
request.session[settings.ADDONS_SESSION_KEY_AUTH] = session_id
return_to = f"{settings.APPLICATION_BASE_URL}/addons/redirect"
return redirect(f"/api/{settings.API_VERSION}/authenticate/?returnTo={return_to}")
@require_http_methods(["GET"])
def redirect_page(request):
"""Complete authentication and close the popup window."""
if not request.user.is_authenticated:
return render_error(request, _("Authentication required."), status=401)
session_id = request.session.pop(settings.ADDONS_SESSION_KEY_AUTH, None)
if not session_id:
return render_error(request, _("No active session found."), status=404)
try:
TokenExchangeService().set_access_token(request.user, session_id)
except SuspiciousOperation:
return render_error(request, _("Invalid or expired session."), status=400)
return render(request, "addons/redirect_success.html")
-113
View File
@@ -1,113 +0,0 @@
"""Add-ons API endpoints"""
from logging import getLogger
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from rest_framework import (
response as drf_response,
)
from rest_framework import decorators
from rest_framework import status as drf_status
from rest_framework import viewsets
from core.addons.service import TokenExchangeService, SessionOwnershipError
logger = getLogger(__name__)
class AuthSessionViewSet(viewsets.ViewSet):
"""ViewSet for managing add-on authentication sessions via token exchange."""
authentication_classes = []
permission_classes = []
throttle_classes = []
def create(self, request):
"""Create a pending session.
Returns session_id in the body (client forwards it to the 3rd-party view).
Sets result_token as an HttpOnly cookie (the only poll credential).
"""
session_id, result_token, transit_token = TokenExchangeService().init_session()
response = drf_response.Response(
{"session_id": session_id, "transit_token": transit_token}, status=drf_status.HTTP_201_CREATED
)
response.set_cookie(
key=settings.ADDONS_RESULT_TOKEN_COOKIE_NAME,
value=result_token,
max_age=6000,
httponly=True,
secure=True,
samesite="None",
)
return response
@decorators.action(
detail=False,
methods=["post"],
url_name="wip",
url_path="wip",
permission_classes=[],
authentication_classes=[],
)
def long_poll(self, request):
"""Long-poll endpoint — only the cookie is accepted, never a session_id.
pk is intentionally ignored; the session is resolved from the cookie.
"""
result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
session_id = request.data.get("session_id")
if not result_token:
return drf_response.Response(
{"detail": "Missing result token."},
status=drf_status.HTTP_401_UNAUTHORIZED,
)
if not session_id:
return drf_response.Response(
{"detail": "Missing result session id."},
status=drf_status.HTTP_401_UNAUTHORIZED,
)
try:
data = TokenExchangeService().get_session_by_token(
result_token=result_token,
claimed_session_id=session_id,
)
except SessionOwnershipError as e:
raise SuspiciousOperation(str(e)) from e
if not data:
return drf_response.Response(
{"detail": "Session not found or expired."},
status=drf_status.HTTP_404_NOT_FOUND,
)
if data.get("state") == "pending":
return drf_response.Response(
{"state": "pending"},
status=drf_status.HTTP_202_ACCEPTED,
)
return drf_response.Response(data, status=drf_status.HTTP_200_OK)
def destroy(self, request, pk=None):
"""Explicit session teardown, resolves via cookie, not pk."""
result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
if not result_token:
return drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT)
# We need the session_id to clear both keys — resolve it first
session_id = TokenExchangeService().token_to_session(result_token)
if session_id:
TokenExchangeService().clear_session(session_id, result_token)
response = drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT)
response.delete_cookie(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
return response
+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
+8 -4
View File
@@ -187,7 +187,7 @@ class UserViewSet(
"""
context = {"request": request}
return drf_response.Response(
self.serializer_class(request.user, context=context).data
serializers.UserMeSerializer(request.user, context=context).data
)
@@ -296,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(
@@ -607,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)
@@ -214,25 +214,6 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
raise exceptions.AuthenticationFailed("Invalid token type.")
class AddonsJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for addons API access.
Validates JWT tokens issued by addons for authenticating users.
Tokens must include user_id to identify the authenticated user.
"""
def __init__(self):
"""Initialize authentication backend with application JWT settings from Django settings."""
super().__init__(
secret_key=settings.ADDONS_JWT_SECRET_KEY,
algorithm=settings.ADDONS_JWT_ALG,
issuer=settings.ADDONS_JWT_ISSUER,
audience=settings.ADDONS_JWT_AUDIENCE,
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
)
class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval."""
@@ -173,7 +173,6 @@ class RoomViewSet(
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
+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'),
),
]
@@ -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 = {
@@ -1,17 +0,0 @@
{% load i18n %}
{% get_current_language as LANGUAGE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="UTF-8">
<title>{% trans "Error" %}</title>
</head>
<body>
<div class="container">
<h1>{{ title|default:_("Error") }}</h1>
<p>{{ message|default:_("Something went wrong.") }}</p>
<button onclick="window.close()">{% trans "Close" %}</button>
</div>
</body>
</html>
@@ -1,17 +0,0 @@
{% load i18n %}
{% get_current_language as LANGUAGE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="UTF-8">
<title>{% trans "Authentication Success" %}</title>
</head>
<body>
<script>
window.close();
</script>
<p>{% trans "Session stored successfully. This window will close automatically." %}</p>
<p>{% trans "If it doesn't close" %}, <a href="javascript:window.close()">{% trans "click here" %}</a>.</p>
</body>
</html>
@@ -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
-28
View File
@@ -6,8 +6,6 @@ from django.urls import include, path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.addons import views as addons_views
from core.addons import viewsets as addons_viewsets
from core.api import get_frontend_configuration, viewsets
from core.external_api import viewsets as external_viewsets
@@ -28,24 +26,12 @@ external_router.register(
basename="external_application",
)
# - Addons API
addons_router = DefaultRouter()
addons_router.register(
"addons/sessions",
addons_viewsets.AuthSessionViewSet,
basename="addons_auth_sessions",
)
external_router.register(
"rooms",
external_viewsets.RoomViewSet,
basename="external_room",
)
addons_urls = addons_router.urls if settings.ADDONS_ENABLED else []
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
@@ -53,26 +39,12 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*addons_urls, # should be in external api
path("config/", get_frontend_configuration, name="config"),
]
),
),
]
if settings.ADDONS_ENABLED:
urlpatterns.append(
path(
"addons/",
include(
[
path("transit/", addons_views.transit_page, name="transit_page"),
path("redirect/", addons_views.redirect_page, name="redirect_page"),
]
),
),
)
if settings.EXTERNAL_API_ENABLED:
urlpatterns.append(
path(
Binary file not shown.
+2 -50
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Sitzungs-ID ist erforderlich."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Sitzung nicht gefunden oder abgelaufen."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Ungültiger Sitzungsstatus."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentifizierung erforderlich."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Keine aktive Sitzung gefunden."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Ungültige oder abgelaufene Sitzung."
#: core/admin.py:29
msgid "Personal info"
msgstr "Persönliche Informationen"
@@ -432,7 +408,7 @@ msgstr "Anwendungsdomain"
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -441,30 +417,6 @@ msgstr "Ihre Aufzeichnung ist bereit"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Fehler"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Etwas ist schiefgelaufen."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Schließen"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentifizierung erfolgreich"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Sitzung erfolgreich gespeichert. Dieses Fenster wird automatisch geschlossen."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Falls es sich nicht schließt"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
Binary file not shown.
+2 -50
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Session ID is required."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Session not found or expired."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Invalid session state."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentication required."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "No active session found."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Invalid or expired session."
#: core/admin.py:29
msgid "Personal info"
msgstr "Personal info"
@@ -429,7 +405,7 @@ msgstr "Application domain"
msgid "Application domains"
msgstr "Application domains"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -438,30 +414,6 @@ msgstr "Your recording is ready"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Error"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Something went wrong."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Close"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentication Success"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Session stored successfully. This window will close automatically."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "If it doesn't close"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
Binary file not shown.
+2 -50
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "L'identifiant de session est requis."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Session introuvable ou expirée."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "État de session invalide."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentification requise."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Aucune session active trouvée."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Session invalide ou expirée."
#: core/admin.py:29
msgid "Personal info"
msgstr "Informations personnelles"
@@ -433,7 +409,7 @@ msgstr "Domaine dapplication"
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -442,30 +418,6 @@ msgstr "Votre enregistrement est prêt"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Erreur"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Une erreur s'est produite."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Fermer"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentification réussie"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Session enregistrée avec succès. Cette fenêtre se fermera automatiquement."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Si elle ne se ferme pas"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
Binary file not shown.
+2 -50
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Sessie-ID is vereist."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Sessie niet gevonden of verlopen."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Ongeldige sessiestatus."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authenticatie vereist."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Geen actieve sessie gevonden."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Ongeldige of verlopen sessie."
#: core/admin.py:29
msgid "Personal info"
msgstr "Persoonlijke informatie"
@@ -428,7 +404,7 @@ msgstr "Applicatiedomein"
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -437,30 +413,6 @@ msgstr "Je opname is klaar"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Fout"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Er is iets misgegaan."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Sluiten"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authenticatie geslaagd"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Sessie succesvol opgeslagen. Dit venster wordt automatisch gesloten."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Als het niet sluit"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
+23 -87
View File
@@ -596,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(
@@ -700,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
@@ -797,93 +820,6 @@ class Base(Configuration):
environ_prefix=None,
)
# Addons
ADDONS_ENABLED = values.BooleanValue(
False,
environ_name="ADDONS_ENABLED",
environ_prefix=None,
)
ADDONS_SESSION_ID_LENGTH = values.PositiveIntegerValue(
32,
environ_name="ADDONS_SESSION_ID_LENGTH",
environ_prefix=None,
)
# Cache key for the session itself (session_id → session data)
ADDONS_SESSION_KEY_PREFIX = values.Value(
"addons_session_id",
environ_name="ADDONS_SESSION_KEY_PREFIX",
environ_prefix=None,
)
# Cache key for the token → session binding (result_token → session_id)
ADDONS_SESSION_TOKEN_PREFIX = values.Value(
"addons_token_id",
environ_name="ADDONS_SESSION_TOKEN_PREFIX",
environ_prefix=None,
)
# Used as the Django session key in transit page
ADDONS_SESSION_KEY_AUTH = values.Value(
"addons_session_id",
environ_name="ADDONS_SESSION_KEY_AUTH",
environ_prefix=None,
)
ADDONS_SESSION_TIMEOUT = values.PositiveIntegerValue(
600, environ_name="ADDONS_SESSION_TIMEOUT", environ_prefix=None
)
ADDONS_RESULT_TOKEN_COOKIE_NAME = values.Value(
"wip",
environ_name="ADDONS_RESULT_TOKEN_COOKIE_NAME",
environ_prefix=None,
)
ADDONS_RESULT_TOKEN_COOKIE_SECURE = values.BooleanValue(
True,
environ_name="ADDONS_RESULT_TOKEN_COOKIE_SECURE",
environ_prefix=None,
)
ADDONS_RESULT_TOKEN_COOKIE_HTTP_ONLY = values.BooleanValue(
True,
environ_name="ADDONS_RESULT_TOKEN_COOKIE_HTTP_ONLY",
environ_prefix=None,
)
ADDONS_RESULT_TOKEN_COOKIE_SAMESITE = values.Value(
"strict",
environ_name="ADDONS_RESULT_TOKEN_COOKIE_SAMESITE",
environ_prefix=None,
)
ADDONS_JWT_SECRET_KEY = SecretFileValue(
None, environ_name="ADDONS_JWT_SECRET_KEY", environ_prefix=None
)
ADDONS_JWT_ALG = values.Value(
"HS256",
environ_name="ADDONS_JWT_ALG",
environ_prefix=None,
)
ADDONS_SCOPES = values.Value(
"rooms:create rooms:list",
environ_name="ADDONS_SCOPES",
environ_prefix=None,
)
ADDONS_JWT_ISSUER = values.Value(
"lasuite-meet",
environ_name="ADDONS_JWT_ISSUER",
environ_prefix=None,
)
ADDONS_JWT_AUDIENCE = values.Value(
None,
environ_name="ADDONS_JWT_AUDIENCE",
environ_prefix=None,
)
ADDONS_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue(
3600,
environ_name="ADDONS_JWT_EXPIRATION_SECONDS",
environ_prefix=None,
)
ADDONS_JWT_TOKEN_TYPE = values.Value(
"Bearer",
environ_name="ADDONS_JWT_TOKEN_TYPE",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
+5 -3
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.8.0"
version = "1.9.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,13 +17,13 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.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.42.49",
"Brotli==1.2.0",
@@ -39,6 +39,7 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.11",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
@@ -50,6 +51,7 @@ dependencies = [
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.2",
"pydantic==2.12.4",
"PyJWT==2.11.0",
"python-frontmatter==1.1.0",
"requests==2.32.5",
-35
View File
@@ -11,41 +11,6 @@ server {
add_header Content-Disposition "attachment; filename=windows-app-web-link";
}
# Manifest — fetched, never iframed
location = /outlook-addin/manifest.xml {
alias /usr/share/nginx/html/outlook-addin/manifest.xml;
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header X-Frame-Options "DENY";
add_header Content-Security-Policy "frame-ancestors 'none'";
}
location ~ ^/outlook-addin(/.*)?$ {
alias /usr/share/nginx/html/outlook-addin$1;
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "no-cache, no-store, must-revalidate";
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
set $nonce $request_id;
set $csp "upgrade-insecure-requests; ";
set $csp "${csp}frame-ancestors ${ms_domains}; ";
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic' ${ms_domains} 'self'; ";
set $csp "${csp}connect-src 'self' 'strict-dynamic' ${ms_domains}; ";
set $csp "${csp}frame-src 'none'; ";
set $csp "${csp}object-src 'none'; ";
set $csp "${csp}base-uri 'none'; ";
add_header Content-Security-Policy $csp;
sub_filter 'NONCE_PLACEHOLDER' $nonce;
sub_filter_once off;
sub_filter_types text/html;
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
+907 -894
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.8.0",
"version": "1.9.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -36,7 +36,7 @@
"livekit-client": "2.17.1",
"posthog-js": "1.342.1",
"react": "18.3.1",
"react-aria-components": "1.10.1",
"react-aria-components": "1.14.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
@@ -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} />
)}
@@ -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)
@@ -13,7 +13,7 @@ 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 = () => {
@@ -58,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) {
@@ -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>
)
@@ -183,7 +183,7 @@ export const ParticipantTile: (
}}
>
{isHandRaised && !isScreenShare && (
<>
<span>
<span>{positionInQueue}</span>
<RiHand
color="black"
@@ -197,7 +197,7 @@ export const ParticipantTile: (
animationIterationCount: '2',
}}
/>
</>
</span>
)}
{isScreenShare && (
<ScreenShareIcon
@@ -210,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" />
@@ -229,9 +231,7 @@ export const ParticipantTile: (
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
{hasKeyboardFocus && (
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
)}
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
</div>
)
})
@@ -1,5 +1,6 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
@@ -87,10 +88,24 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce()
useRegisterKeyboardShortcut({
id: deviceShortcut?.id,
handler: async () => await toggle(),
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,
})
@@ -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({
@@ -12,6 +12,7 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useFullScreen } from '../../hooks/useFullScreen'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
@@ -21,6 +22,8 @@ export function DesktopControlBar({
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
useRegisterKeyboardShortcut({
id: 'focus-toolbar',
handler: () => {
@@ -32,6 +35,13 @@ export function DesktopControlBar({
firstButton?.focus()
},
})
useRegisterKeyboardShortcut({
id: 'fullscreen',
handler: toggleFullScreen,
isDisabled: !isFullscreenAvailable,
})
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'
@@ -97,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) => {
@@ -111,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 },
@@ -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
@@ -3,15 +3,20 @@ import { ShortcutRow } from '@/features/shortcuts/components/ShortcutRow'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
import { H } from '@/primitives'
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)',
@@ -29,12 +34,11 @@ export const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<H lvl={2}>{t('shortcuts.listLabel')}</H>
<table className={tableStyle}>
<thead className="sr-only">
<caption>{t('shortcuts.listLabel')}</caption>
<thead>
<tr>
<th scope="col">{t('shortcuts.columnAction')}</th>
<th scope="col">{t('shortcuts.columnShortcut')}</th>
@@ -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
@@ -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,
}
}
@@ -5,6 +5,7 @@ import { Shortcut } from './types'
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId =
| 'open-shortcuts'
| 'focus-toolbar'
| 'toggle-microphone'
| 'toggle-camera'
@@ -29,6 +30,11 @@ export type ShortcutDescriptor = {
}
export const shortcutCatalog: ShortcutDescriptor[] = [
{
id: 'open-shortcuts',
category: 'navigation',
shortcut: { key: '/', ctrlKey: true, shiftKey: true },
},
{
id: 'focus-toolbar',
category: 'navigation',
@@ -25,9 +25,9 @@ export const ShortcutBadge: React.FC<ShortcutBadgeProps> = ({
}) => {
return (
<>
<div className={cx(badgeStyle, className)} aria-hidden="true">
<span>{visualLabel}</span>
</div>
<kbd className={cx(badgeStyle, className)} aria-hidden="true">
{visualLabel}
</kbd>
{srLabel && <span className="sr-only">{srLabel}</span>}
</>
)
@@ -31,9 +31,9 @@ export const ShortcutRow: React.FC<ShortcutRowProps> = ({ descriptor }) => {
return (
<tr>
<td className={text({ variant: 'body' })}>
<th scope="row" className={text({ variant: 'body' })}>
{t(`actions.${descriptor.id}`)}
</td>
</th>
<td className={shortcutCellStyle}>
<ShortcutBadge visualLabel={visualShortcut} srLabel={srShortcut} />
</td>
@@ -19,7 +19,10 @@ export const useKeyboardShortcuts = () => {
shiftKey,
altKey,
})
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
let shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut && shortcutKey === 'ctrl+shift+?') {
shortcut = shortcutsSnap.shortcuts.get('ctrl+shift+/')
}
if (!shortcut) return
e.preventDefault()
await shortcut()
+3
View File
@@ -5,6 +5,7 @@ import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio'
import { Footer } from '@/layout/Footer'
import { ScreenReaderAnnouncer } from '@/primitives'
import { SkipLink, MAIN_CONTENT_ID } from './SkipLink'
export type Layout = 'fullpage' | 'centered'
@@ -21,6 +22,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
{showHeader && <SkipLink />}
<div
className={css({
display: 'flex',
@@ -35,6 +37,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
>
{showHeader && <Header />}
<main
id={MAIN_CONTENT_ID}
className={css({
flexGrow: 1,
overflow: 'auto',
+69
View File
@@ -0,0 +1,69 @@
import { type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
export const MAIN_CONTENT_ID = 'main-content'
// Visually hidden until focus (not sr-only). Must become visible on focus for keyboard users.
const StyledSkipLink = styled('a', {
base: {
position: 'absolute',
width: '1px',
height: '1px',
margin: '-1px',
padding: 0,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
textDecoration: 'none',
_focusVisible: {
position: 'fixed',
top: '0.5rem',
left: '50%',
transform: 'translateX(-50%)',
width: 'auto',
height: 'auto',
margin: 0,
padding: '0.625rem 1rem',
overflow: 'visible',
clip: 'auto',
whiteSpace: 'normal',
zIndex: 9999,
backgroundColor: 'white',
color: 'primary.800',
fontWeight: 500,
fontSize: '0.875rem',
border: '1px solid',
borderColor: 'primary.800',
borderRadius: 4,
outline: '2px solid',
outlineColor: 'focusRing',
outlineOffset: 2,
},
},
})
export const SkipLink = () => {
const { t } = useTranslation()
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
const main = document.getElementById(MAIN_CONTENT_ID)
if (!main) return
const heading = main.querySelector('h1, h2, h3') as HTMLElement | null
const target = heading ?? main
if (!target.hasAttribute('tabindex')) {
target.setAttribute('tabindex', '-1')
}
target.focus()
}
return (
<StyledSkipLink href={`#${MAIN_CONTENT_ID}`} onClick={handleClick}>
{t('skipLink')}
</StyledSkipLink>
)
}
+3 -1
View File
@@ -25,6 +25,7 @@
"heading": "Überprüfen Sie Ihren Meeting-Code",
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
},
"selected": "ausgewählt",
"submit": "OK",
"footer": {
"links": {
@@ -45,13 +46,14 @@
"license": "Etalab 2.0 Lizenz"
},
"loginHint": {
"title": "Melden Sie sich mit Ihrem ProConnect-Konto an",
"title": "Melden Sie sich mit Ihrem Konto an",
"body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
"button": {
"ariaLabel": "Hinweis schließen",
"label": "OK"
}
},
"skipLink": "Zum Hauptinhalt springen",
"clipboardContent": {
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
+10 -5
View File
@@ -10,6 +10,7 @@
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
"joinMeetingTipHeading": "Wussten Sie schon?",
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
"noAccess": "Sie haben keinen Zugang zur Erstellung von Meetings. Bitte kontaktieren Sie Ihren Administrator.",
"moreLinkLabel": "Mehr erfahren neues Tab",
"moreLink": "Mehr erfahren",
"moreAbout": "über {{appTitle}}",
@@ -31,12 +32,14 @@
},
"introSlider": {
"previous": {
"label": "Zurück",
"tooltip": "Zurück"
"label": "Vorherige Folie",
"labelWithPosition": "Vorherige Folie ({{current}} von {{total}})",
"tooltip": "Vorherige Folie"
},
"next": {
"label": "Weiter",
"tooltip": "Weiter"
"label": "Nächste Folie",
"labelWithPosition": "Nächste Folie ({{current}} von {{total}})",
"tooltip": "Nächste Folie"
},
"beta": {
"text": "An der Beta teilnehmen",
@@ -53,6 +56,8 @@
"slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI",
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!"
}
},
"carouselLabel": "Einführungs-Diashow",
"slidePosition": "Folie {{current}} von {{total}}"
}
}
+5 -1
View File
@@ -22,6 +22,8 @@
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
"disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren",
"turnedOff": "Kamera deaktiviert",
"turnedOn": "Kamera aktiviert",
"label": "Kamera",
"placeholder": "Kamera aktivieren, um die Vorschau zu sehen"
},
@@ -30,6 +32,8 @@
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
"turnedOff": "Mikrofon deaktiviert",
"turnedOn": "Mikrofon aktiviert",
"label": "Mikrofon"
},
"audiooutput": {
@@ -586,7 +590,7 @@
},
"participantTileFocus": {
"containerLabel": "Optionen für {{name}}",
"toolbarHint": "F2: zur Symbolleiste unten.",
"toolbarHint": "Ctrl+Shift+/: Direkt auf die Tastenkürzel zugreifen.",
"pin": {
"enable": "Anheften",
"disable": "Lösen"
+3 -1
View File
@@ -25,6 +25,7 @@
"heading": "Verify your meeting code",
"body": "Check that you have entered the correct meeting code in the URL. Example:"
},
"selected": "selected",
"submit": "OK",
"footer": {
"links": {
@@ -45,13 +46,14 @@
"license": "etalab 2.0 license"
},
"loginHint": {
"title": "Log in with your ProConnect account",
"title": "Log in with your account",
"body": "Instead of waiting, log in with your ProConnect account.",
"button": {
"ariaLabel": "Close the suggestion",
"label": "OK"
}
},
"skipLink": "Skip to main content",
"clipboardContent": {
"url": "To join the video conference, click on this link: {{roomUrl}}",
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
+10 -5
View File
@@ -10,6 +10,7 @@
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
"joinMeetingTipHeading": "Did you know?",
"loginToCreateMeeting": "Login to create a meeting",
"noAccess": "You do not have access to create meetings. Please contact your administrator.",
"moreLinkLabel": "Learn more - new tab",
"moreLink": "Learn more",
"moreAbout": "about {{appTitle}}",
@@ -31,12 +32,14 @@
},
"introSlider": {
"previous": {
"label": "previous",
"tooltip": "previous"
"label": "Previous slide",
"labelWithPosition": "Previous slide ({{current}} of {{total}})",
"tooltip": "Previous slide"
},
"next": {
"label": "next",
"tooltip": "next"
"label": "Next slide",
"labelWithPosition": "Next slide ({{current}} of {{total}})",
"tooltip": "Next slide"
},
"beta": {
"text": "Join the beta",
@@ -53,6 +56,8 @@
"slide3": {
"title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
}
},
"carouselLabel": "Introduction slideshow",
"slidePosition": "Slide {{current}} of {{total}}"
}
}
+5 -1
View File
@@ -22,6 +22,8 @@
"permissionsNeeded": "Select camera - permission needed",
"disable": "Disable camera",
"enable": "Enable camera",
"turnedOff": "Camera turned off",
"turnedOn": "Camera turned on",
"label": "Camera",
"placeholder": "Enable camera to see the preview"
},
@@ -30,6 +32,8 @@
"permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone",
"enable": "Enable microphone",
"turnedOff": "Microphone turned off",
"turnedOn": "Microphone turned on",
"label": "Microphone"
},
"audiooutput": {
@@ -586,7 +590,7 @@
},
"participantTileFocus": {
"containerLabel": "Options for {{name}}",
"toolbarHint": "F2: go to the bottom toolbar.",
"toolbarHint": "Ctrl+Shift+/: access shortcuts directly.",
"pin": {
"enable": "Pin",
"disable": "Unpin"
+3 -1
View File
@@ -25,6 +25,7 @@
"heading": "Vérifier votre code de réunion",
"body": "Vérifiez que vous avez saisi le code de réunion correct dans l'URL. Exemple :"
},
"selected": "sélectionné",
"submit": "OK",
"footer": {
"links": {
@@ -45,13 +46,14 @@
"license": "licence etalab 2.0"
},
"loginHint": {
"title": "Connectez-vous avec votre compte ProConnect",
"title": "Connectez-vous avec votre compte",
"body": "Au lieu de patienter, connectez-vous avec votre compte ProConnect.",
"button": {
"ariaLabel": "Fermer la suggestion",
"label": "OK"
}
},
"skipLink": "Aller au contenu principal",
"clipboardContent": {
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"
+9 -4
View File
@@ -10,6 +10,7 @@
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
"joinMeetingTipHeading": "Astuce",
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
"noAccess": "Vous n'avez pas accès à la création de réunions. Veuillez contacter votre administrateur.",
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
"moreLink": "En savoir plus",
"moreAbout": "sur {{appTitle}}",
@@ -30,14 +31,18 @@
}
},
"introSlider": {
"carouselLabel": "Diaporama de présentation",
"previous": {
"label": "précédent",
"tooltip": "précédent"
"label": "Diapositive précédente",
"labelWithPosition": "Diapositive précédente ({{current}} sur {{total}})",
"tooltip": "Diapositive précédente"
},
"next": {
"label": "suivant",
"tooltip": "suivant"
"label": "Diapositive suivante",
"labelWithPosition": "Diapositive suivante ({{current}} sur {{total}})",
"tooltip": "Diapositive suivante"
},
"slidePosition": "Diapositive {{current}} sur {{total}}",
"beta": {
"text": "Essayer la beta",
"tooltip": "Accéder au formulaire"

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