Compare commits

..

8 Commits

Author SHA1 Message Date
Martin Guitteny 7f2308e040 📈(summary) delete observability span for minio
Delete useless span for observability minio get_object
2025-10-03 10:16:54 +02:00
Martin Guitteny a9e9cffc21 🎨(summary) create observability class
Create observability class to be more maintenable
Change LLM calling from langfuse to openai to hide prompts
2025-10-03 10:16:38 +02:00
Martin Guitteny 398afe17c5 fixup! 📈(summary) add observability with langfuse 2025-10-01 14:30:01 +02:00
Martin Guitteny 983c7fae9f fixup! 📈(summary) add observability with langfuse 2025-10-01 14:24:52 +02:00
Martin Guitteny 4ad6b79624 fixup! 📈(summary) add observability with langfuse 2025-10-01 14:21:13 +02:00
Martin Guitteny 0d51cdc948 📈(summary) add langfuse trace on stt call
Add a trace on the speach-to-text model
for better observability (time spent,errors,
model used, lenght of the audio used...)
2025-10-01 13:30:33 +02:00
Martin Guitteny 0f73bccfdd 📈(summary) add observability with langfuse
add Langfuse observability instruments
add trace with @observe around
process_audio_transcribe_summarize_v2 and summarize_transcription
create span for minio
wrap LLM calls in generation spans
ensure trace delivery with langfuse.flush
2025-10-01 11:58:57 +02:00
Martin Guitteny 244ed6e9ca (summary) add dependency and env variables for langfuse
update depencies to add langfuse
add keys and host in config for langfuse to prepare
for observabilty
2025-09-19 16:46:34 +02:00
150 changed files with 1096 additions and 6857 deletions
+7 -9
View File
@@ -14,8 +14,6 @@ on:
env:
DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
jobs:
build-and-push-backend:
@@ -29,7 +27,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
images: lasuite/meet-backend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -42,7 +40,7 @@ jobs:
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 }}'
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -65,7 +63,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
images: lasuite/meet-frontend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -78,7 +76,7 @@ jobs:
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 }}'
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -102,7 +100,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
images: lasuite/meet-frontend-dinum
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -115,7 +113,7 @@ jobs:
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 }}'
docker-image-name: 'docker.io/lasuite/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -139,7 +137,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
images: lasuite/meet-summary
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
-30
View File
@@ -34,32 +34,6 @@ jobs:
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
lint-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
if [ $max_line_length -ge 80 ]; then
echo "ERROR: CHANGELOG has lines longer than 80 characters."
exit 1
fi
build-mails:
runs-on: ubuntu-latest
defaults:
@@ -209,10 +183,6 @@ jobs:
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OIDC_RS_CLIENT_ID: meet
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
OIDC_OP_URL: https://oidc.example.com
steps:
- name: Checkout repository
+2 -11
View File
@@ -1,3 +1,4 @@
# Changelog
All notable changes to this project will be documented in this file.
@@ -8,14 +9,4 @@ and this project adheres to
## [Unreleased]
## [1.0.1] - 2025-12-17
### Changes
- ♿(frontend) improve accessibility:
- ♿️(frontend) Improve SR and focus for transcript and recording #810
- ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807
- 🔧(backend) support `_FILE` for secret environment variables #566
+65
View File
@@ -45,6 +45,7 @@ COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
WAIT_MINIO = @$(COMPOSE_RUN) dockerize -wait tcp://minio:9000 -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -53,6 +54,21 @@ MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
# -- Frontend
PATH_FRONT = ./src/frontend
# -- MinIO / Webhook
MINIO_ALIAS ?= meet
MINIO_ENDPOINT ?= http://127.0.0.1:9000
MINIO_ACCESS_KEY ?= meet
MINIO_SECRET_KEY ?= password
MINIO_BUCKET ?= meet-media-storage
MINIO_WEBHOOK_NAME ?= recording
MINIO_WEBHOOK_ENDPOINT ?= http://app-dev:8000/api/v1.0/recordings/storage-hook/
MINIO_QUEUE_DIR ?= /data/minio/events
MINIO_QUEUE_LIMIT ?= 100000
STORAGE_EVENT_TOKEN ?= password
# ==============================================================================
# RULES
@@ -137,6 +153,55 @@ stop: ## stop the development server using Docker
@$(COMPOSE) stop
.PHONY: stop
# -- MinIO webhook (configuration & events)
minio-wait: ## wait for minio to be ready
@echo "$(BOLD)Waiting for MinIO$(RESET)"
$(WAIT_MINIO)
.PHONY: minio-wait
minio-queue-dir: minio-wait ## ensure queue dir exists in MinIO container
@echo "$(BOLD)Ensuring MinIO queue dir$(RESET)"
@$(COMPOSE) exec minio sh -lc 'mkdir -p $(MINIO_QUEUE_DIR) && chmod -R 777 $(dir $(MINIO_QUEUE_DIR)) || true'
.PHONY: minio-queue-dir
minio-alias: minio-wait ## set mc alias to MinIO
@echo "$(BOLD)Setting mc alias $(MINIO_ALIAS) -> $(MINIO_ENDPOINT)$(RESET)"
@mc alias set $(MINIO_ALIAS) $(MINIO_ENDPOINT) $(MINIO_ACCESS_KEY) $(MINIO_SECRET_KEY) >/dev/null
.PHONY: minio-alias
minio-webhook-config: minio-alias minio-queue-dir ## configure webhook on MinIO
@echo "$(BOLD)Configuring MinIO webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc admin config set $(MINIO_ALIAS) notify_webhook:$(MINIO_WEBHOOK_NAME) \
endpoint="$(MINIO_WEBHOOK_ENDPOINT)" \
auth_token="Bearer $(STORAGE_EVENT_TOKEN)" \
queue_dir="$(MINIO_QUEUE_DIR)" \
queue_limit="$(MINIO_QUEUE_LIMIT)"
.PHONY: minio-webhook-config
minio-restart: minio-alias ## restart MinIO after config change
@echo "$(BOLD)Restarting MinIO service$(RESET)"
@mc admin service restart $(MINIO_ALIAS)
.PHONY: minio-restart
minio-events-reset: minio-alias ## remove all bucket notifications
@echo "$(BOLD)Removing existing bucket events on $(MINIO_BUCKET)$(RESET)"
@mc event remove --force $(MINIO_ALIAS)/$(MINIO_BUCKET) >/dev/null || true
.PHONY: minio-events-reset
minio-event-add: minio-alias ## add put event -> webhook
@echo "$(BOLD)Adding put event -> webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc event add $(MINIO_ALIAS)/$(MINIO_BUCKET) arn:minio:sqs::$(MINIO_WEBHOOK_NAME):webhook --event put
@mc event list $(MINIO_ALIAS)/$(MINIO_BUCKET)
.PHONY: minio-event-add
minio-webhook-setup: ## full setup: alias, config, restart, reset events, add event
minio-webhook-setup: \
minio-webhook-config \
minio-restart \
minio-events-reset \
minio-event-add
.PHONY: minio-webhook-setup
# -- Front
frontend-development-install: ## install the frontend locally
+9 -26
View File
@@ -34,10 +34,9 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Non-persistent, secure chat
- End-to-end encryption (coming soon)
- Meeting recording
- Meeting transcription & Summary (currently in beta)
- Meeting transcription (currently in beta)
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
- LiveKit Advances features including :
- speaker detection
- simulcast
@@ -55,7 +54,6 @@ Were continuously adding new features to enhance your experience, with the la
- [Get started](#get-started)
- [Docs](#docs)
- [Self-host](#self-host)
- [Contributing](#contributing)
- [Philosophy](#philosophy)
- [Open source](#open-source)
@@ -63,37 +61,22 @@ Were continuously adding new features to enhance your experience, with the la
## Get started
### La Suite Meet Cloud (Recommended)
Sign up for La Suite Meet Cloud, designed for french public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
### Open-source deployment (Advanced)
Deploy La Suite Meet on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
## Docs
We're currently working on both technical and user documentation for La Suite Meet. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
## Self-host
### La Suite Meet is easy to install on your own servers
We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
**Questions?** Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
> [!NOTE]
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
#### Known instances
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Access |
|---------------------------------------------------------------| --- | ------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [meet.demo.mosacloud.eu](https://meet.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing
We <3 contributions of any kind, big and small:
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/11/views/4)
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/3/views/2)
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
-160
View File
@@ -1,160 +0,0 @@
#!/bin/bash
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
# Function to update npm package version
update_npm_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
npm version "$VERSION" --no-git-tag-version
cd -
}
# Function to update Python project version in pyproject.toml
update_python_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
if [ ! -f "pyproject.toml" ]; then
print_error "pyproject.toml not found in src/$component!"
exit 1
fi
if grep -q '^version = "' pyproject.toml; then
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
rm pyproject.toml.bak
print_info "Updated pyproject.toml version to $VERSION"
else
print_error "Could not find version line in pyproject.toml"
exit 1
fi
cd -
}
# Check if we're in a git repository
if ! git rev-parse --git-dir > /dev/null 2>&1; then
print_error "Not a git repository. Please run this script from the root of your project."
exit 1
fi
# Check if working directory is clean
if ! git diff-index --quiet HEAD --; then
print_error "Working directory is not clean. Please commit or stash your changes first."
exit 1
fi
# Ask user for release version number
echo ""
read -p "Enter release version number (e.g., 1.2.3): " VERSION
# Validate version format (basic semver check)
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
print_error "Invalid version format. Please use semantic versioning (e.g., 1.2.3)"
exit 1
fi
print_info "Release version: $VERSION"
# Check if branch already exists
BRANCH_NAME="release/$VERSION"
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
print_error "Branch $BRANCH_NAME already exists!"
exit 1
fi
# Create and checkout new branch
print_info "Creating branch: $BRANCH_NAME"
git checkout -b "$BRANCH_NAME"
# Update frontend
update_npm_version "frontend"
# Update SDK
update_npm_version "sdk"
# Update mail
update_npm_version "mail"
# Update backend pyproject.toml
update_python_version "backend"
# Update summary pyproject.toml
update_python_version "summary"
# Update agents pyproject.toml
update_python_version "agents"
# Update CHANGELOG
print_info "Updating CHANGELOG..."
if [ ! -f "CHANGELOG.md" ]; then
print_error "CHANGELOG.md not found in project root!"
exit 1
fi
# Get current date in YYYY-MM-DD format
CURRENT_DATE=$(date +%Y-%m-%d)
# Replace [Unreleased] with [version number] - YYYY-MM-DD
if grep -q '\[Unreleased\]' CHANGELOG.md; then
sed -i.bak "s/\[Unreleased\]/[$VERSION] - $CURRENT_DATE/" CHANGELOG.md
# Add new [Unreleased] section after the header
# This adds it after the line containing "Semantic Versioning"
sed -i.bak "/Semantic Versioning/a\\
\\
## [Unreleased]
" CHANGELOG.md
rm CHANGELOG.md.bak
print_info "Updated CHANGELOG.md"
else
print_warning "Could not find [Unreleased] section in CHANGELOG.md"
fi
# Summary
echo ""
print_info "Release preparation complete!"
echo ""
echo "Summary:"
echo " - Branch created: $BRANCH_NAME"
echo " - Version updated to: $VERSION"
echo " - Files modified:"
echo " - src/frontend/package.json"
echo " - src/sdk/package.json"
echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - CHANGELOG.md"
echo ""
print_warning "Next steps:"
echo " 1. Review the changes: git status"
echo " 2. Commit the changes: git add . && git commit -m 'Release $VERSION'"
echo " 3. Push the branch: git push origin $BRANCH_NAME"
echo ""
-26
View File
@@ -46,21 +46,6 @@ services:
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
createwebhook:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password &&
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json &&
sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
exit 0;"
app-dev:
build:
context: .
@@ -87,12 +72,8 @@ services:
- nginx
- livekit
- createbuckets
- createwebhook
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
networks:
- resource-server
- default
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -148,9 +129,6 @@ services:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- keycloak
networks:
- resource-server
- default
frontend:
user: "${DOCKER_USER:-1000}"
@@ -304,7 +282,3 @@ services:
watch:
- action: rebuild
path: ./src/summary
networks:
default:
resource-server:
+2 -7
View File
@@ -24,7 +24,7 @@ RUN npm run build
# Inject PostHog sourcemap metadata into the built assets
# This metadata is essential for correctly mapping errors to source maps in production
RUN set -e && \
npx @posthog/cli@0.4.8 sourcemap inject --directory ./dist/assets
npx @posthog/cli sourcemap inject --directory ./dist/assets
COPY ./docker/dinum-frontend/dinum-styles.css \
./dist/assets/
@@ -42,12 +42,7 @@ COPY ./docker/dinum-frontend/fonts/ \
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 \
libcrypto3 \
libxml2>=2.12.7-r2 \
libxslt>=1.1.39-r2 \
libexpat>=2.7.2-r0 \
libpng>=1.6.53-r0
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
USER nginx
@@ -1,50 +0,0 @@
upstream meet_backend {
server ${BACKEND_HOST}:8000 fail_timeout=0;
}
upstream meet_frontend {
server ${FRONTEND_HOST}:8080 fail_timeout=0;
}
server {
listen 8083;
server_name localhost;
charset utf-8;
# Disables server version feedback on pages and in headers
server_tokens off;
proxy_ssl_server_name on;
location @proxy_to_meet_backend {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://meet_backend;
}
location @proxy_to_meet_frontend {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://meet_frontend;
}
location / {
try_files $uri @proxy_to_meet_frontend;
}
location /api {
try_files $uri @proxy_to_meet_backend;
}
location /admin {
try_files $uri @proxy_to_meet_backend;
}
location /static {
try_files $uri @proxy_to_meet_backend;
}
}
-23
View File
@@ -1,23 +0,0 @@
version: '3'
# You can add any necessary service here that will join the same docker network
# sharing keycloak. Services added to the 'meet_resource-server' network will be
# able to communicate with keycloak and the backend on that network.
services:
# busybox service is only used for testing purposes. It provides curl to test
# connectivity to the backend and keycloak services. Replace this with your
# relevant application services that need to communicate with keycloak.
busybox:
image: alpine:latest
privileged: true
command: sh -c "apk add --no-cache curl && sleep infinity"
stdin_open: true
tty: true
networks:
- default
- meet_resource-server
networks:
default: {}
meet_resource-server:
external: true
+81
View File
@@ -0,0 +1,81 @@
# LiveKit Egress
LiveKit offers Universal Egress, designed to provide universal exports of LiveKit sessions or tracks to a file or stream data.
It is kept in a separate system to keep the load off the [Single Forwarding Unit (SFU)](https://docs.livekit.io/reference/internals/livekit-sfu/) and avoid impacting real-time audio or video performance/quality.
## Getting started
### Prerequisite
1. **Verify Services**: Ensure the LiveKit server and Egress service are both up and running.
2. **Install CLI**: Confirm that the LiveKit CLI utility is installed on your system.
3. **Set Permissions**: Since the Egress service does not run as the root user, you need to grant write permissions to all users for the output directory. Update the permissions of the `docker/livekit/out` folder before starting the docker-compose stack:
```bash
$ chmod o+w ./docker/livekit/out
```
### Make a recording
LiveKit provides examples for creating Egress requests, which you can find [here](https://github.com/livekit/livekit-cli/tree/main/cmd/livekit-cli/examples). One of these examples has been added to the repository under `docker/livekit/egress-example`.
Follow these steps to start an Egress request:
1. **Create a Room**: Create a room either through the frontend or using the `livekit-cli` command.
2. **Retrieve Room Name**: Get the room's name (e.g., the UUID4 in the URL from the frontend).
3. **Update Configuration**: Edit the `docker/livekit/egress-example/room-composite-file.json` file with your room's name.
4. **Start Egress Request**: Initiate a new Egress request.
```bash
$ livekit-cli start-room-composite-egress --request ./docker/livekit/egress-example/room-composite-file.json
Using default project meet
EgressID: EG_XXXXXXXXXXXX Status: EGRESS_STARTING
```
You can list running Egress:
```Bash
$ livekit-cli list-egress
Using default project meet
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
| EGRESSID | STATUS | TYPE | SOURCE | STARTED AT | ERROR |
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
| EG_XXXXXXXXXXXX | EGRESS_ACTIVE | room_composite | your-room-name-XXXXXXXXXXX-XXXXXXXXX | 2024-07-05 18:11:37.073847924 | |
| | | | | +0200 CEST | |
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
```
You can stop the Egress at any time once your recording is finished:
```Bash
$ livekit-cli stop-egress --id EG_XXXXXXXXXXXX
Using default project meet
Stopping Egress EG_XXXXXXXXXXXX
```
The Egress should be marked as completed:
```bash
$ livekit-cli list-egress
Using default project meet
+-----------------+-----------------+----------------+--------------------------------------+--------------------------------+-------+
| EGRESSID | STATUS | TYPE | SOURCE | STARTED AT | ERROR |
+-----------------+-----------------+----------------+--------------------------------------+--------------------------------+-------+
| EG_XXXXXXXXXXXX | EGRESS_COMPLETE | room_composite | your-room-name-XXXXXXXXXXX-XXXXXXXXX | 2024-07-05 18:11:37.073847924 | |
| | | | | +0200 CEST | |
+-----------------+-----------------+----------------+--------------------------------------+--------------------------------+-------+
```
Finally, you should find two new files in the `./docker/livekit/out directory`: an `.mp4` recording and its associated metadata in a `.json` file:
```bash
$ ls ./docker/livekit/out
your-room-name-YYYY-MM-DDTHHMMSS.mp4
your-room-name-YYYY-MM-DDTHHMMSS.mp4.json
```
### Resources
[Official Egress repository](https://github.com/livekit/egress)
-88
View File
@@ -1,88 +0,0 @@
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/postgresql
- env.d/common
volumes:
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:5
backend:
image: lasuite/meet-backend:latest
user: ${DOCKER_USER:-1000}
restart: always
env_file:
- env.d/common
- env.d/backend
- env.d/postgresql
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 15s
timeout: 30s
retries: 20
start_period: 10s
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
livekit:
condition: service_started
frontend:
image: lasuite/meet-frontend:latest
user: "${DOCKER_USER:-1000}"
entrypoint:
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
env_file:
- env.d/common
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=${MEET_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${MEET_HOST} # used by lets encrypt to generate TLS certificate
depends_on:
backend:
condition: service_healthy
volumes:
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
livekit:
image: livekit/livekit-server:latest
command: --config /config.yaml
ports:
- 7881:7881/tcp
- 7882:7882/udp
volumes:
- ./livekit-server.yaml:/config.yaml
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=${LIVEKIT_HOST} # used by nginx proxy
# - VIRTUAL_PORT=7880 # used by nginx proxy
# - LETSENCRYPT_HOST=${LIVEKIT_HOST} # used by lets encrypt to generate TLS certificate
depends_on:
redis:
condition: service_started
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
-92
View File
@@ -1,92 +0,0 @@
# Deploy and Configure Keycloak for Meet
## Installation
> [!CAUTION]
> We provide those instructions as an example, for production environments, you should follow the [official documentation](https://www.keycloak.org/documentation).
### Step 1: Prepare your working environment:
```bash
mkdir keycloak/env.d && cd keycloak
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
```
### Step 2:. Update `env.d/` files
The following variables need to be updated with your own values, others can be left as is:
```env
POSTGRES_PASSWORD=<generate postgres password>
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
```
### Step 3: Expose keycloak instance on https
> [!NOTE]
> You can skip this section if you already have your own setup.
To access your Keycloak instance on the public network, it needs to be exposed on a domain with SSL termination. You can use our [example with nginx proxy and Let's Encrypt companion](../nginx-proxy/README.md) for automated creation/renewal of certificates using [acme.sh](http://acme.sh).
If following our example, uncomment the environment and network sections in compose file and update it with your values.
```yaml
version: '3'
services:
keycloak:
...
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
...
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
```
### Step 4: Start the service
```bash
`docker compose up -d`
```
Your keycloak instance is now available on https://doc.yourdomain.tld
> [!CAUTION]
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
```
## Creating an OIDC Client for Meet Application
### Step 1: Create a New Realm
1. Log in to the Keycloak administration console.
2. Navigate to the realm tab and click on the "Create realm" button.
3. Enter the name of the realm - `meet`.
4. Click "Create".
#### Step 2: Create a New Client
1. Navigate to the "Clients" tab.
2. Click on the "Create client" button.
3. Enter the client ID - e.g. `meet`.
4. Enable "Client authentication" option.
6. Set the "Valid redirect URIs" to the URL of your meet application suffixed with `/*` - e.g., "https://meet.example.com/*".
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
1. Click "Save".
#### Step 3: Get Client Credentials
1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret.
@@ -1,36 +0,0 @@
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/kc_postgresql
volumes:
- ./data/keycloak:/var/lib/postgresql/data/pgdata
keycloak:
image: quay.io/keycloak/keycloak:latest
command: ["start"]
env_file:
- env.d/kc_postgresql
- env.d/keycloak
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
depends_on:
postgresql:
condition: service_healthy
restart: true
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
#
#networks:
# proxy-tier:
# external: true
@@ -1,39 +0,0 @@
# Nginx proxy with automatic SSL certificates
> [!CAUTION]
> We provide those instructions as an example, for extended development or production environments, you should follow the [official documentation](https://github.com/nginx-proxy/acme-companion/tree/main/docs).
Nginx-proxy sets up a container running nginx and docker-gen. docker-gen generates reverse proxy configs for nginx and reloads nginx when containers are started and stopped.
Acme-companion is a lightweight companion container for nginx-proxy. It handles the automated creation, renewal and use of SSL certificates for proxied Docker containers through the ACME protocol.
## Installation
### Step 1: Prepare your working environment:
```bash
mkdir nginx-proxy && cd nginx-proxy
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
```
### Step 2: Edit `DEFAULT_EMAIL` in the compose file.
Albeit optional, it is recommended to provide a valid default email address through the `DEFAULT_EMAIL` environment variable, so that Let's Encrypt can warn you about expiring certificates and allow you to recover your account.
### Step 3: Create docker network
Containers need share the same network for auto-discovery.
```bash
docker network create proxy-tier
```
### Step 4: Start service
```bash
docker compose up -d
```
## Usage
Once both nginx-proxy and acme-companion containers are up and running, start any container you want proxied with environment variables `VIRTUAL_HOST` and `LETSENCRYPT_HOST` both set to the domain(s) your proxied container is going to use.
@@ -1,36 +0,0 @@
services:
nginx-proxy:
image: nginxproxy/nginx-proxy
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
volumes:
- html:/usr/share/nginx/html
- certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro
networks:
- proxy-tier
acme-companion:
image: nginxproxy/acme-companion
container_name: nginx-proxy-acme
environment:
- DEFAULT_EMAIL=mail@yourdomain.tld
volumes_from:
- nginx-proxy
volumes:
- certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- proxy-tier
networks:
proxy-tier:
external: true
volumes:
html:
certs:
acme:
-22
View File
@@ -1,22 +0,0 @@
port: 7880
redis:
address: redis:6379
keys:
meet: <your livekit secret key>
# WebRTC configuration
rtc:
# # when set, LiveKit will attempt to use a UDP mux so all UDP traffic goes through
# # listed port(s). To maximize system performance, we recommend using a range of ports
# # greater or equal to the number of vCPUs on the machine.
# # port_range_start & end must not be set for this config to take effect
udp_port: 7882
# when set, LiveKit enable WebRTC ICE over TCP when UDP isn't available
# this port *cannot* be behind load balancer or TLS, and must be exposed on the node
# WebRTC transports are encrypted and do not require additional encryption
# only 80/443 on public IP are allowed if less than 1024
tcp_port: 7881
# use_external_ip should be set to true for most cloud environments where
# the host has a public IP address, but is not exposed to the process.
# LiveKit will attempt to use STUN to discover the true IP, and advertise
# that IP with its clients
use_external_ip: true
-53
View File
@@ -1,53 +0,0 @@
# Authentication (OIDC)
La Suite Meet supports **OIDC authentication** using the Authorization Code Flow.
Authentication relies on [django-lasuite](https://github.com/suitenumerique/django-lasuite) for OIDC integration, token validation, and user management.
## OIDC Configuration
| Option | Description | Default |
|-------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------------------------ |
| **Client Settings** | | |
| OIDC_RP_CLIENT_ID | OIDC client identifier registered with your provider | `meet` |
| OIDC_RP_CLIENT_SECRET | OIDC client secret (keep confidential) | — |
| OIDC_CREATE_USER | Automatically create a local user if none exists | `true` |
| **Security & Verification** | | |
| OIDC_VERIFY_SSL | Verify SSL certificates when contacting the OIDC provider | `true` |
| OIDC_USE_NONCE | Use `nonce` to prevent replay attacks | `true` |
| OIDC_STORE_ID_TOKEN | Store the ID token returned by the OIDC provider (useful for backend validation) | `true` |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to identifying users by email if `sub` claim does not match. Enable only if emails are unique. | `false` |
| **Endpoints** | | |
| OIDC_OP_JWKS_ENDPOINT | URL to retrieve JSON Web Key Sets (for token verification) | — |
| OIDC_OP_AUTHORIZATION_ENDPOINT | URL for authorization requests | — |
| OIDC_OP_TOKEN_ENDPOINT | URL to exchange authorization code for tokens | — |
| OIDC_OP_USER_ENDPOINT | URL to fetch user information | — |
| OIDC_OP_USER_ENDPOINT_FORMAT | Format of user endpoint response. Options: `AUTO` (detect automatically), `JWT`, or `JSON` | `AUTO` |
| OIDC_OP_LOGOUT_ENDPOINT | URL for logout requests | — |
| **User Info Mapping** | | |
| OIDC_USERINFO_FULLNAME_FIELDS | List of OIDC claims used to build users full name | `["given_name", "usual_name"]` |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC claim used for the users short name | `given_name` |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | List of essential claims required from the provider | `[]` |
| **Redirects & Scopes** | | |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC redirect URIs (**recommended in production**) | `false` |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirects | `[]` |
| OIDC_REDIRECT_FIELD_NAME | Query parameter name used for redirect after login | `returnTo` |
| OIDC_RP_SCOPES | Scopes to request during authentication | `openid email` |
| LOGIN_REDIRECT_URL | URL to redirect after successful login | — |
| LOGIN_REDIRECT_URL_FAILURE | URL to redirect after failed login | — |
| LOGOUT_REDIRECT_URL | URL to redirect after logout | — |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through HTTP GET (POST is recommended for security) | `true` |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters to include in OIDC authentication requests | `{}` |
| **PKCE (Proof Key for Code Exchange)** | | |
| OIDC_USE_PKCE | Enable PKCE for enhanced security (**recommended**) | `false` |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method to generate PKCE code challenge (`S256` recommended) | `S256` |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as PKCE code verifier (43128 characters) | `64` |
| **Other** | | |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Silent login allows La Suite Meet to authenticate users automatically without showing a login prompt, providing a seamless experience when an active session already exists with the OIDC provider. It works by replaying the authentication request with prompt=none: if the user has a valid session, login succeeds silently; otherwise, it fails gracefully and redirects the user to the initial page. Silent login is optional and enabled by default in standard deployments. The app retries silent login after any 401 response, with at least a 30-second interval between attempts (not configurable via environment variables). Controlled by the backend parameter. /!\ Your OIDC provider must support `prompt=none`. | `false` |
## Sessions
* After login, users receive a **Django session cookie** to maintain authentication across requests.
* Default session duration is 12 hours (`SESSION_COOKIE_AGE = 60 * 60 * 12`).
* Ensure your session policy matches your security requirements.
-6
View File
@@ -1,6 +0,0 @@
# Calendar integrations (WIP)
These features are currently under active development and are not yet ready for official documentation. Comprehensive documentation will be provided as soon as possible.
An initial integration with OpenExchange is already available and will be documented shortly.
-143
View File
@@ -1,143 +0,0 @@
# Room Recording (Beta)
La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned.
The feature allows users to record their room sessions. When a recording is complete, the room owner receives a notification with a link to download the recorded file. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
**Current Limitations**:
* Users cannot record and transcribe simultaneously. ([Issue #527](https://github.com/suitenumerique/meet/issues/527)
is on our backlog)
* Recording layout cannot be configured from the frontend. By default, the egress captures the active speaker and any shared screens. (not yet planned)
* Shareable links with an embedded video player are not yet supported. (not yet planned)
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
## Special requirements
To use the room recording feature, the following components are required:
- A running [LiveKit Egress](https://github.com/livekit/egress) server capable of handling room composite recordings.
- A S3-compatible object storage that supports webhook events to notify the backend when recordings are uploaded.
- An email service to notify room owners when a recording is available for download.
- Webhook events configured between LiveKit Server and the backend.
> [!CAUTION]
> Minio supports lifecycle events; other providers may not work out of the box. There is currently a dependency on Minio, which is planned to be refactored in the future.
> [!NOTE]
> Celery isnt in use for these async tasks yet. Its something wed like to add, but its not planned at this stage.
## How It Works
```mermaid
sequenceDiagram
participant User
participant Frontend as Frontend (React)
participant Backend as Django Backend
participant LiveKit as LiveKit API
participant Egress as LiveKit Egress
participant Storage as Object Storage
participant Room as LiveKit Room
participant Email as Email Service
User->>Frontend: Click start recording button
Frontend->>Backend: POST /api/v1.0/rooms/{id}/start-recording/
Backend->>LiveKit: Create egress request
LiveKit->>Egress: Start room composite egress
Egress->>Room: Join room as recording participant
Note over Egress,Room: Egress joins room to capture audio/video
LiveKit-->>Backend: Return egress_id
Backend->>Backend: Update Recording with worker_id
Backend-->>Frontend: HTTP 201 - Recording started
Frontend->>Frontend: Update recording status
Frontend->>Frontend: Notify other participants
Note over Frontend: Via LiveKit data channel
Note over Egress,Room: Recording in progress...
User->>Frontend: Click stop recording button
Frontend->>Backend: POST /api/v1.0/rooms/{id}/stop-recording/
Backend->>LiveKit: Stop egress request
LiveKit->>Egress: Stop recording
Egress->>Storage: Upload recorded file
Storage->>Backend: Storage event notification
Backend->>Backend: Update Recording status to SAVED
Backend->>Email: Send notification to room owner
Backend-->>Frontend: HTTP 200 - Recording stopped
Frontend->>Frontend: Update UI and notify participants
Email->>User: Send email with recording link
User->>Frontend: Navigate to /recording/{id} to download file
Frontend->>Frontend: Download recording file
```
## Configuration Options
| Option | Type | Default | Description |
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). |
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
### Manual Storage Webhook
Storage events must be configured manually; the Kubernetes chart does not do this automatically.
1. Configure your S3 bucket to send file creation events to the backend webhook.
2. Enable events and token in settings:
```python
RECORDING_STORAGE_EVENT_ENABLE = True
RECORDING_ENABLE_STORAGE_EVENT_AUTH = True
RECORDING_STORAGE_EVENT_TOKEN = <token>
```
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
## LiveKit Egress
La Suite Meet uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
Currently, only `RoomCompositeEgress` is supported. This mode combines all video and audio tracks from the room into a single recording.
To monitor egress workers and inspect recording status, it is recommended to install `livekit-cli`. For example, you can list active egress sessions using the following command:
```bash
$ livekit-cli list-egress
Using default project meet
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
| EGRESSID | STATUS | TYPE | SOURCE | STARTED AT | ERROR |
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
| EG_XXXXXXXXXXXX | EGRESS_ACTIVE | room_composite | your-room-name-XXXXXXXXXXX-XXXXXXXXX | 2024-07-05 18:11:37.073847924 | |
| | | | | +0200 CEST | |
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
```
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
-24
View File
@@ -1,24 +0,0 @@
# Signaling
Signaling is essential for LiveKits real-time communication. It enables peers to discover each other, exchange session descriptions, and negotiate network paths for audio and video streams.
## How Signaling Works
LiveKit signaling relies on a WebSocket connection between the client and the LiveKit API server. This WebSocket is required for all signaling messages, including session descriptions, ICE candidates, and connection state updates.
We do not cover internal signaling behavior. For full reference, see the [LiveKit client protocol](https://docs.livekit.io/reference/internals/client-protocol/).
> [!IMPORTANT]
> The WebSocket is the backbone of LiveKit signaling. All signaling messages rely on it, and without it, ICE candidate exchange and peer connection setup cannot occur. If the WebSocket connection is lost, the client automatically attempts to resume the RTC session once connectivity is restored.
## Environment Variables
| Variable | Type | Default | Purpose |
| ----------------------------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `LIVEKIT_FORCE_WSS_PROTOCOL` | Boolean | `True` | Forces the WebSocket URL to use `wss://`. Required for legacy browsers (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in `WebSocket()` may fail. |
| `LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND` | Boolean | `True` | Workaround for Firefox clients behind proxies that fail to establish WebSocket connections. Pre-establishes a dummy connection to “prime” the WebSocket. |
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
-4
View File
@@ -1,4 +0,0 @@
# Live subtitles (WIP)
This feature is currently under development and not yet ready for production use. Documentation and detailed instructions will be provided once the feature is stable and officially released.
-4
View File
@@ -1,4 +0,0 @@
# Meeting summarization (WIP)
This feature is currently under development and not yet ready for production use. Documentation and detailed instructions will be provided once the feature is stable and officially released.
-87
View File
@@ -1,87 +0,0 @@
# Telephony SIP (Beta)
Enable participants to join a video conference via phone, allowing them to participate in the room even when their internet connection is poor or unavailable.
**Current Limitations**:
* Supports only a single SIP trunk provider per instance.
* A participant joining over the phone cannot enter the room until the first WebRTC participant has connected.
## Special requirements
To use the telephony feature, the following components are required:
* A running [LiveKit SIP server](https://github.com/livekit/sip) ([documentation](https://docs.livekit.io/home/self-hosting/sip-server/)) to handle SIP participants and connect them to room sessions.
* A SIP trunk to route incoming and outgoing phone calls.
* Webhook events configured between the LiveKit server and the backend.
## How It Works
### Room Lifecycle
```mermaid
sequenceDiagram
participant Backend
participant LiveKit as LiveKit Service
participant SIP as LiveKit SIP
participant Dispatch as SIP Dispatch
Backend->>Backend: Create new room
Backend->>Backend: Assign unique pin code to room
LiveKit-->>Backend: Webhook room_started
Backend->>Dispatch: Create LiveKit SIP dispatch rule
LiveKit-->>Backend: Webhook room_ended
Backend->>Dispatch: Clear LiveKit SIP dispatch rule
```
### Participant calling
```mermaid
sequenceDiagram
participant Caller as Caller
participant SIPProvider as SIP Trunk Provider
participant LiveKitSIP as LiveKit SIP
participant Dispatch as SIP Dispatch
participant Room as LiveKit Room
Caller->>SIPProvider: Dial phone number
SIPProvider->>LiveKitSIP: Route call to SIP server
LiveKitSIP->>Caller: Prompt for room pin code
Caller->>LiveKitSIP: Enter pin code
LiveKitSIP->>Dispatch: Check dispatch rule for pin and trunk ID
Dispatch-->>LiveKitSIP: Return room ID if found
LiveKitSIP->>Room: Connect participant to room
```
## Configuration
| Option | Type | Default | Description |
| ------------------------------ | ---------------- | ------- |-----------------------------------------------------------------------------------------------------------------|
| ROOM_TELEPHONY_ENABLED | Boolean | False | Enable or disable telephony (phone call) support for rooms. |
| ROOM_TELEPHONY_PIN_LENGTH | Positive Integer | 10 | Length of the PIN code participants must enter to join a call. |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Positive Integer | 5 | Maximum number of attempts a participant can make when entering the PIN. |
| ROOM_TELEPHONY_PHONE_NUMBER | String | None | The phone number associated with the room for incoming calls. Required to route calls via the telephony system. |
| ROOM_TELEPHONY_DEFAULT_COUNTRY | String | "US" | Default country code for phone numbers, used for parsing and formatting phone numbers. |
### SIP Trunk Authentication
You may need to configure authentication between LiveKit SIP and your SIP trunk provider to enable participants to join via phone.
Please refer to [the official documentation](https://docs.livekit.io/sip/quickstarts/configuring-sip-trunk/).
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
### Language Customization for Audio Prompts
You may need to configure the default LiveKit voice to match your locale. By default, all LiveKit audio instructions are in English.
To customize the prompts, mount the appropriate audio files as a volume in your deployment. The audio resources are available here: [LiveKit SIP audio files](https://github.com/livekit/sip/tree/main/res).
## Documentation
For detailed information on integrating and configuring SIP with LiveKit, refer to the official LiveKit SIP documentation: [LiveKit SIP Documentation](https://docs.livekit.io/sip/). This guide covers SIP server setup, trunk configuration, dispatch rules, etc.
-92
View File
@@ -1,92 +0,0 @@
# Transcription
La Suite Meet provides a room transcription capability, currently available in beta. This feature is under active development, with ongoing enhancements planned.
The transcription feature enables users to record room sessions. Upon completion of a recording, the room owner receives a notification containing a link to LaSuite Docs, where the transcribed meeting content can be accessed.
> [!NOTE]
> Audio recordings are automatically deleted after the configured `RECORDING_EXPIRATION_DAYS` period.
For configuration and setup details of the recording functionality, refer to the [Recording feature documentation](https://github.com/suitenumerique/meet/blob/main/docs/features/recording.md).
This page only describes the supplementary tools required for audio processing.
Example of a transcript :
```
**SPEAKER_00**: Hello everyone!
**SPEAKER_01**: Yes, it works.
```
### Current Limitations
* Participant identification is not yet implemented; participants are labeled generically (e.g., `PARTICIPANT_1`).
* Transcription backend relies on [WhisperX](https://github.com/m-bain/whisperX), which does not provide an OpenAI-compatible API.
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
## Special requirements
To enable the transcription feature, the following components must be in place:
* Recording feature components: All dependencies and configurations required for the [recording feature](https://github.com/suitenumerique/meet/blob/main/docs/features/recording.md).
* LaSuite Docs instance: A running [LaSuite Docs](https://github.com/suitenumerique/docs) capable of handling requests to the `/create-for-owner` endpoint.
* WhisperX API: A running WhisperX service. An open-source implementation combining WhisperX and FastAPI is available [here](https://github.com/suitenumerique/meet-whisperx).
* Deployment of the [summary service](https://hub.docker.com/r/lasuite/meet-summary), a Celery worker, and a Redis instance.
## How It Works
```mermaid
sequenceDiagram
participant Backend as Backend API
participant Summary as Summary Service
participant Celery as Celery Workers (transcribe-queue)
participant MinIO as MinIO (Object Storage)
participant STT as WhisperX API
participant Docs as LaSuite Docs
Backend->>Summary: POST /api/v1/tasks/ (bearer token, payload)
Note right of Backend: Payload contains 7 params: owner_id, filename, email, sub, room, recording_date, recording_time
Summary->>Celery: Register task (transcribe-queue)
Celery->>MinIO: Fetch audio file
Celery->>STT: Transcribe audio (WhisperX)
STT-->>Celery: Segmented transcript
Celery->>Celery: Format transcript (text)
Celery->>Docs: POST /create-for-owner (title, content, email, sub, api token)
Docs-->>Celery: Acknowledgement
```
## Configuration Options
| Option | Type | Default | Description |
| ------------------------ | --------- |-----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| app_name | String | `"app"` | Name of the application/service. |
| app_api_v1_str | String | `"/api/v1"` | Base path for the API endpoints. |
| app_api_token | Secret | — | API token for authenticating requests. |
| recording_max_duration | Integer | `None` | Maximum duration of audio recordings in milliseconds. Set to `None` for unlimited. Audio recordings longer than the configured limit will be ignored and not processed. |
| celery_broker_url | String | `"redis://redis/0"` | Celery broker URL. |
| celery_result_backend | String | `"redis://redis/0"` | Celery result backend URL. |
| celery_max_retries | Integer | `1` | Maximum number of retries for Celery tasks. |
| transcribe_queue | String | `"transcribe-queue"` | Name of the Celery queue for transcription tasks. |
| aws_storage_bucket_name | String | — | Name of the S3/MinIO bucket used for storing recordings. |
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3/MinIO storage. |
| aws_s3_access_key_id | String | — | Access key for S3/MinIO. |
| aws_s3_secret_access_key | Secret | — | Secret key for S3/MinIO. |
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3/MinIO requests. |
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
| whisperx_max_retries | Integer | `0` | Maximum number of retries for WhisperX API requests. |
| webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. |
| webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. |
| webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. |
| webhook_api_token | Secret | — | Token to authenticate incoming webhook requests. |
| webhook_url | String | — | URL to which webhook events are sent. |
| document_default_title | String | `"Transcription"` | Default title for generated documents. |
| document_title_template | String | `'Réunion "{room}" du {room_recording_date} à {room_recording_time}'` | Template for document title. |
| sentry_is_enabled | Boolean | `False` | Enable or disable Sentry error tracking. |
| sentry_dsn | String | `None` | DSN for Sentry integration. |
-25
View File
@@ -1,25 +0,0 @@
# Installation
If you want to install La Suite Meet you've come to the right place.
Here are a bunch of resources to help you install the project.
## Kubernetes
La Suite Meet maintainers use only the Kubernetes deployment method in production, so advanced support is available exclusively for this setup. Please follow the instructions provided [here](/docs/installation/kubernetes.md).
## Docker Compose
We understand that not everyone has a Kubernetes cluster available, please follow the instructions provided [here](/docs/installation/compose.md) to set up a docker compose instance.
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
## Other ways to install La Suite Meet
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
Here is the list of other methods in alphabetical order:
- Nix: [Packages](https://search.nixos.org/packages?channel=unstable&show=lasuite-meet&query=lasuite-meet), ⚠️ unstable
- Yunohost: [Packages](https://github.com/YunoHost-Apps/meet_ynh), ⚠️ under construction (for small instances only)
> [!TIP]
> Feel free to make a PR to add ones that are not listed above
## Cloud providers
Currently, no cloud providers are listed for deploying La Suite Meet.
> [!TIP]
> Feel free to make a PR to add ones that are not listed above
-227
View File
@@ -1,227 +0,0 @@
# Installation with docker compose
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
## Requirements
All services are required to run the minimalist instance of LaSuite Meet. Click the links for ready-to-use configuration examples:
| Service | Purpose | Example Config |
|-------------------|---------|----------------------------------------------------------|
| **PostgreSQL** | Main database | [compose.yaml](../examples/compose/compose.yaml) |
| **Redis** | Cache & sessions | [compose.yaml](../examples/compose/compose.yaml) |
| **Livekit** | Real-time communication | [compose.yaml](../examples/compose/compose.yaml) |
| **OIDC Provider** | User authentication | [Keycloak setup](../examples/compose/keycloak/README.md) |
| **SMTP Service** | Email notifications | - |
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (MinIO, email). See `/features` folder for details.
## Software Requirements
Ensure you have Docker Compose(v2) installed on your host server. Follow the official guidelines for a reliable setup:
Docker Compose is included with Docker Engine:
- **Docker Engine:** We suggest adhering to the instructions provided by Docker
for [installing Docker Engine](https://docs.docker.com/engine/install/).
For older versions of Docker Engine that do not include Docker Compose:
- **Docker Compose:** Install it as per the [official documentation](https://docs.docker.com/compose/install/).
> [!NOTE]
> `docker-compose` may not be supported. You are advised to use `docker compose` instead.
## Step 1: Prepare your working environment:
```bash
mkdir -p meet/env.d && cd meet
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/compose.yaml
curl -o .env https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/hosts
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/common
curl -o env.d/postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/postgresql
curl -o livekit-server.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/livekit/server.yaml
curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docker/files/production/default.conf.template
```
## Step 2: Configuration
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
In this example, we assume the following services:
- OIDC provider on https://id.yourdomain.tld
- Livekit server on https://livekit.yourdomain.tld
- Meet server on https://meet.yourdomain.tld
**Set your own values in `.env`**
### OIDC
Authentication in Meet is managed through Open ID Connect protocol. A functional Identity Provider implementing this protocol is required.
For guidance, refer to our [Keycloak deployment example](../examples/compose/keycloak/README.md).
If using Keycloak as your Identity Provider, in `env.d/common` set `OIDC_RP_CLIENT_ID` and `OIDC_RP_CLIENT_SECRET` variables with those of the OIDC client created for Meet. By default we have set `meet` as the realm name, if you have named your realm differently, update the value `REALM_NAME` in `.env`
For others OIDC providers, update the variables in `env.d/common`.
### Postgresql
Meet uses PostgreSQL as its database. Although an external PostgreSQL can be used, our example provides a deployment method.
If you are using the example provided, you need to generate a secure key for `DB_PASSWORD` and set it in `env.d/postgresql`.
If you are using an external service or not using our default values, you should update the variables in `env.d/postgresql`
### Redis
Meet uses Redis for caching and inter-service communication. While an external Redis can be used, our example provides a deployment method.
If you are using an external service, you need to set `REDIS_URL` environment variable in `env.d/common`.
### Livekit
[LiveKit](https://github.com/livekit/livekit) server is used as the WebRTC SFU (Selective Forwarding Unit) allowing multi-user conferencing. For more information, head to [livekit documentation](https://docs.livekit.io/home/self-hosting/).
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
We provide a minimal recommanded config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
> [!NOTE]
> In this example, we configured multiplexing on a single UDP port. For better performances, you can configure a range of UDP ports.
### Meet
The Meet backend is built on the Django Framework.
Generate a [secure key](https://docs.djangoproject.com/en/5.2/ref/settings/#secret-key.) for `DJANGO_SECRET_KEY` in `env.d/common`.
### Mail
The following environment variables are required in `env.d/common` for the mail service to send invitations :
```env
DJANGO_EMAIL_HOST=<smtp host>
DJANGO_EMAIL_HOST_USER=<smtp user>
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
DJANGO_EMAIL_PORT=<smtp port>
DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
```
## Step 3: Configure your firewall
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
The following ports will need to be opened:
- 80/tcp - for TLS issuance
- 443/tcp - for listening on HTTPS and TURN/TLS packets
- 7881/tcp - WebRTC ICE over TCP
- 7882/udp - for WebRTC multiplexing over UDP
If you are using ufw, enter the follwoing:
```
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 443/udp
ufw allow 7881/tcp
ufw allow 7882/udp
ufw enable
```
## Step 4: Reverse proxy and SSL/TLS
> [!WARNING]
> In a production environment, configure SSL/TLS termination to run your instance on https.
If you have your own certificates and proxy setup, you can skip this part.
You can follow our [nginx proxy example](../examples/compose/nginx-proxy/README.md) with automatic generation and renewal of certificate with Let's Encrypt.
You will need to uncomment the environment and network sections in compose file and update it with your values.
```yaml
frontend:
...
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=${MEET_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${MEET_HOST} # used by lets encrypt to generate TLS certificate
...
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
...
# environment:
# - VIRTUAL_HOST=${LIVEKIT_HOST} # used by nginx proxy
# - VIRTUAL_PORT=7880 # used by nginx proxy
# - LETSENCRYPT_HOST=${LIVEKIT_HOST} # used by lets encrypt to generate TLS certificate
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
#networks:
# proxy-tier:
# external: true
```
## Step 5: Start Meet
You are ready to start your Meet application !
```bash
docker compose up -d
```
> [!NOTE]
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image.
## Step 6: Run the database migration and create Django admin user
```bash
docker compose run --rm backend python manage.py migrate
docker compose run --rm backend python manage.py createsuperuser --email <admin email> --password <admin password>
```
Replace `<admin email>` with the email of your admin user and generate a secure password.
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
THe admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
## How to upgrade your Meet application
Before running an upgrade you must check the [Upgrade document](../../UPGRADE.md) for specific procedures that might be needed.
You can also check the [Changelog](../../CHANGELOG.md) for brief summary of the changes.
### Step 1: Edit the images tag with the desired version
### Step 2: Pull the images
```bash
docker compose pull
```
### Step 3: Restart your containers
```bash
docker compose restart
```
### Step 4: Run the database migration
Your database schema may need to be updated, run:
```bash
docker compose run --rm backend python manage.py migrate
```
-475
View File
@@ -1,475 +0,0 @@
openapi: 3.0.3
info:
title: Meet External API
version: 1.0.0
description: |
External API for room management with application-delegated authentication.
#### Authentication Flow
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token`.
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
3. Tokens are scoped and allow applications to act on behalf of specific users.
#### Scopes
* `rooms:list` List rooms accessible to the delegated user.
* `rooms:retrieve` Retrieve details of a specific room.
* `rooms:create` Create new rooms.
* `rooms:update` **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `rooms:delete` **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
* **Create rooms for unknown users from the web app:** Support for generating rooms for users who are not yet registered in the system.
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
contact:
name: API Support
email: antoine.lebaud@mail.numerique.gouv.fr
servers:
- url: https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0
description: Sandbox server
tags:
- name: Authentication
description: Application authentication and token generation
- name: Rooms
description: Room management operations
paths:
/application/token:
post:
tags:
- Authentication
summary: Generate JWT token
description: |
Exchange application credentials for a scoped JWT token that allows the application
to act on behalf of a specific user.
The application must be authorized for the user's email domain.
The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
operationId: generateToken
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TokenRequest'
examples:
tokenRequest:
summary: Request token for user delegation
value:
client_id: "550e8400-e29b-41d4-a716-446655440000"
client_secret: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type: "client_credentials"
scope: "user@example.com"
responses:
'200':
description: Token generated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
examples:
tokenResponse:
summary: Successful token generation
value:
access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
token_type: "Bearer"
expires_in: 3600
scope: "rooms:list rooms:retrieve rooms:create"
'401':
description: Authentication failed
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthError'
examples:
invalidCredentials:
summary: Invalid credentials
value:
error: "Invalid credentials"
inactiveApplication:
summary: Application is inactive
value:
error: "Application is inactive"
'400':
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthError'
examples:
userNotFound:
summary: User not found
value:
error: "User not found."
'403':
description: Access denied - cannot delegate user
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthError'
examples:
delegationDenied:
summary: Domain not authorized
value:
error: "This application is not authorized for this email domain."
/rooms:
get:
tags:
- Rooms
summary: List rooms
description: |
Returns a list of rooms accessible to the authenticated user.
Only rooms where the delegated user has access will be returned.
operationId: listRooms
security:
- BearerAuth: [rooms:list]
parameters:
- name: page
in: query
description: Page number for pagination
schema:
type: integer
minimum: 1
default: 1
- name: page_size
in: query
description: Number of items per page
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
'200':
description: List of accessible rooms
content:
application/json:
schema:
type: object
properties:
count:
type: integer
description: Total number of rooms
next:
type: string
nullable: true
description: URL to next page
previous:
type: string
nullable: true
description: URL to previous page
results:
type: array
items:
$ref: '#/components/schemas/Room'
examples:
roomList:
summary: Paginated room list
value:
count: 2
next: "https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0/rooms?page=2"
previous: null
results:
- id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug: "aae-erez-aaz"
access_level: "trusted"
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
telephony:
enabled: true
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
post:
tags:
- Rooms
summary: Create a room
description: |
Creates a new room with secure defaults for external API usage.
**Restrictions:**
- Rooms are always created with `trusted` access (no public rooms via API)
- Room access_level can be updated from the webapp interface.
**Defaults:**
- Delegated user is set as owner
- Room slug auto-generated for uniqueness
- Telephony PIN auto-generated when enabled
- Creation tracked with application client_id for auditing
operationId: createRoom
security:
- BearerAuth: [rooms:create]
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/RoomCreate'
examples:
emptyBody:
summary: No parameters (default)
value: {}
responses:
'201':
description: Room created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
/rooms/{id}:
get:
tags:
- Rooms
summary: Retrieve a room
description: Get detailed information about a specific room by its ID
operationId: retrieveRoom
security:
- BearerAuth: [rooms:retrieve]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
responses:
'200':
description: Room details
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
examples:
room:
summary: Room details
value:
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug: "aae-erez-aaz"
access_level: "trusted"
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
telephony:
enabled: true
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/application/token` endpoint.
Include in requests as: `Authorization: Bearer <token>`
schemas:
TokenRequest:
type: object
required:
- client_id
- client_secret
- grant_type
- scope
properties:
client_id:
type: string
description: Application client identifier
example: "550e8400-e29b-41d4-a716-446655440000"
client_secret:
type: string
format: password
writeOnly: true
description: Application secret key
example: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type:
type: string
enum:
- client_credentials
description: OAuth2 grant type (must be 'client_credentials')
example: "client_credentials"
scope:
type: string
format: email
description: |
Email address of the user to delegate.
The application will act on behalf of this user.
Note: This parameter is named 'scope' to align with OAuth2 conventions,
but accepts an email address to identify the user. This design allows
for future extensibility.
example: "user@example.com"
TokenResponse:
type: object
properties:
access_token:
type: string
description: JWT access token
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
token_type:
type: string
description: Token type (always 'Bearer')
example: "Bearer"
expires_in:
type: integer
description: Token lifetime in seconds
example: 3600
scope:
type: string
description: Space-separated list of granted permission scopes
example: "rooms:list rooms:retrieve rooms:create"
RoomCreate:
type: object
description: Empty object - all room properties are auto-generated
properties: {}
Room:
type: object
properties:
id:
type: string
format: uuid
readOnly: true
description: Unique room identifier
example: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug:
type: string
readOnly: true
description: URL-friendly room identifier (auto-generated)
example: "aze-eere-zer"
access_level:
type: string
readOnly: true
description: Room access level (always 'trusted' for API-created rooms)
example: "trusted"
url:
type: string
format: uri
readOnly: true
description: Full URL to access the room
example: "https://visio-sandbox.beta.numerique.gouv.fr/aze-eere-zer"
telephony:
type: object
readOnly: true
description: Telephony dial-in information (if enabled)
properties:
enabled:
type: boolean
description: Whether telephony is available
example: true
pin_code:
type: string
description: PIN code for dial-in access
example: "123456"
phone_number:
type: string
description: Phone number to dial
example: "+1-555-0100"
default_country:
type: string
description: Default country code
example: "US"
OAuthError:
type: object
description: OAuth2-compliant error response
properties:
error:
type: string
description: Human-readable error description
example: "Invalid credentials"
Error:
type: object
properties:
detail:
type: string
description: Error message
example: "Invalid token."
ValidationError:
type: object
properties:
field_name:
type: array
items:
type: string
description: List of validation errors for this field
example: ["This field is required."]
responses:
UnauthorizedError:
description: Authentication required or token invalid/expired
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
invalidToken:
summary: Invalid or expired token
value:
error: "Invalid token."
tokenExpired:
summary: Token has expired
value:
error: "Token expired."
ForbiddenError:
description: Insufficient permissions for this operation
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
insufficientScope:
summary: Missing required scope
value:
detail: "Insufficient permissions. Required scope: 'rooms:xxxx'"
RoomNotFoundError:
description: Room not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
roomNotFound:
summary: Room does not exist
value:
detail: "Not found."
BadRequestError:
description: Invalid request data
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
examples:
validationError:
summary: Field validation failed
value:
scope: ["Invalid email address."]
+65
View File
@@ -0,0 +1,65 @@
# Releasing a new version
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
...
frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
```
The new images don't exist _yet_: they will be created automatically later in the process.
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
```text
🔖(minor) bump release to 4.18.0
```
5. Open a pull request, wait for an approval from your peers and merge it.
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
7. Tag and push your commit:
```bash
git tag v4.18.1 && git push origin --tags
```
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/meet-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/meet-frontend/tags) image tags are on Docker Hub.
9. The release is now done!
# Deploying
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
Making a new release doesn't publish it automatically in production.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```
-393
View File
@@ -1,393 +0,0 @@
openapi: 3.0.3
info:
title: Meet External API
version: 1.0.0
description: |
External API for room management with resource server authentication.
[[description by Oauth 2.0]](https://www.oauth.com/oauth2-servers/the-resource-server/)
#### Authentication Flow
1. Authenticate with the authorization server using your credentials
2. During authentication, request the scopes you need: `lasuite_visio` (mandatory) plus action-specific scopes
3. Receive an access token and a refresh token that includes the requested scopes
4. Use the access token in the `Authorization: Bearer <token>` header for all API requests
5. When the access token expires, use the refresh token to obtain a new access token without re-authenticating
#### Scopes
* `lasuite_visio` - **Mandatory** Base scope required for any API access
* `lasuite_visio:rooms:list` List rooms accessible to the delegated user.
* `lasuite_visio:rooms:retrieve` Retrieve details of a specific room.
* `lasuite_visio:rooms:create` Create new rooms.
* `lasuite_visio:rooms:update` **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `lasuite_visio:rooms:delete` **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
contact:
name: API Support
email: antoine.lebaud@mail.numerique.gouv.fr
servers:
- url: https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0
description: Sandbox server
tags:
- name: Rooms
description: Room management operations
paths:
/rooms:
get:
tags:
- Rooms
summary: List rooms
description: |
Returns a list of rooms accessible to the authenticated user.
Only rooms where the delegated user has access will be returned.
operationId: listRooms
security:
- BearerAuth: [rooms:list]
parameters:
- name: page
in: query
description: Page number for pagination
schema:
type: integer
minimum: 1
default: 1
- name: page_size
in: query
description: Number of items per page
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
'200':
description: List of accessible rooms
content:
application/json:
schema:
type: object
properties:
count:
type: integer
description: Total number of rooms
next:
type: string
nullable: true
description: URL to next page
previous:
type: string
nullable: true
description: URL to previous page
results:
type: array
items:
$ref: '#/components/schemas/Room'
examples:
roomList:
summary: Paginated room list
value:
count: 2
next: "https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0/rooms?page=2"
previous: null
results:
- id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug: "aae-erez-aaz"
access_level: "trusted"
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
telephony:
enabled: true
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
post:
tags:
- Rooms
summary: Create a room
description: |
Creates a new room with secure defaults for external API usage.
**Restrictions:**
- Rooms are always created with `trusted` access (no public rooms via API)
- Room access_level can be updated from the webapp interface.
**Defaults:**
- Delegated user is set as owner
- Room slug auto-generated for uniqueness
- Telephony PIN auto-generated when enabled
- Creation tracked with application client_id for auditing
operationId: createRoom
security:
- BearerAuth: [rooms:create]
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/RoomCreate'
examples:
emptyBody:
summary: No parameters (default)
value: {}
responses:
'201':
description: Room created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
/rooms/{id}:
get:
tags:
- Rooms
summary: Retrieve a room
description: Get detailed information about a specific room by its ID
operationId: retrieveRoom
security:
- BearerAuth: [rooms:retrieve]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
responses:
'200':
description: Room details
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
examples:
room:
summary: Room details
value:
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug: "aae-erez-aaz"
access_level: "trusted"
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
telephony:
enabled: true
pin_code: "123456"
phone_number: "+1-555-0100"
default_country: "US"
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/application/token` endpoint.
Include in requests as: `Authorization: Bearer <token>`
schemas:
TokenRequest:
type: object
required:
- client_id
- client_secret
- grant_type
- scope
properties:
client_id:
type: string
description: Application client identifier
example: "550e8400-e29b-41d4-a716-446655440000"
client_secret:
type: string
format: password
writeOnly: true
description: Application secret key
example: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type:
type: string
enum:
- client_credentials
description: OAuth2 grant type (must be 'client_credentials')
example: "client_credentials"
scope:
type: string
format: email
description: |
Email address of the user to delegate.
The application will act on behalf of this user.
Note: This parameter is named 'scope' to align with OAuth2 conventions,
but accepts an email address to identify the user. This design allows
for future extensibility.
example: "user@example.com"
TokenResponse:
type: object
properties:
access_token:
type: string
description: JWT access token
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
token_type:
type: string
description: Token type (always 'Bearer')
example: "Bearer"
expires_in:
type: integer
description: Token lifetime in seconds
example: 3600
scope:
type: string
description: Space-separated list of granted permission scopes
example: "rooms:list rooms:retrieve rooms:create"
RoomCreate:
type: object
description: Empty object - all room properties are auto-generated
properties: {}
Room:
type: object
properties:
id:
type: string
format: uuid
readOnly: true
description: Unique room identifier
example: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
slug:
type: string
readOnly: true
description: URL-friendly room identifier (auto-generated)
example: "aze-eere-zer"
access_level:
type: string
readOnly: true
description: Room access level (always 'trusted' for API-created rooms)
example: "trusted"
url:
type: string
format: uri
readOnly: true
description: Full URL to access the room
example: "https://visio-sandbox.beta.numerique.gouv.fr/aze-eere-zer"
telephony:
type: object
readOnly: true
description: Telephony dial-in information (if enabled)
properties:
enabled:
type: boolean
description: Whether telephony is available
example: true
pin_code:
type: string
description: PIN code for dial-in access
example: "123456"
phone_number:
type: string
description: Phone number to dial
example: "+1-555-0100"
default_country:
type: string
description: Default country code
example: "US"
OAuthError:
type: object
description: OAuth2-compliant error response
properties:
error:
type: string
description: Human-readable error description
example: "Invalid credentials"
Error:
type: object
properties:
detail:
type: string
description: Error message
example: "Invalid token."
ValidationError:
type: object
properties:
field_name:
type: array
items:
type: string
description: List of validation errors for this field
example: ["This field is required."]
responses:
UnauthorizedError:
description: The access token is expired, revoked, malformed, or invalid for other reasons. The client can obtain a new access token and try again.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
invalidToken:
summary: Invalid token
value:
error: "Invalid token."
ForbiddenError:
description: Insufficient scope for this operation
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
insufficientScope:
summary: Missing required scope
value:
detail: "Insufficient permissions. Required scope: 'rooms:xxxx'"
RoomNotFoundError:
description: Room not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
roomNotFound:
summary: Room does not exist
value:
detail: "Not found."
BadRequestError:
description: Invalid request data
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
examples:
validationError:
summary: Field validation failed
value:
scope: ["Invalid email address."]
-118
View File
@@ -1,118 +0,0 @@
# Theming La Suite Meet
There are two ways to customize LaSuite Meet:
- **Runtime Theming**. You can load a custom CSS file to apply any CSS you want. You can change all design-system tokens through CSS variables: colors, fonts, spacing multipliers, and more.
- **Build-time Theming**. Some additional things, like the app name appearing in the browser tab, can be customized through environment variables that are applied at build-time.
## Runtime Theming
### How to Use
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript
FRONTEND_CSS_URL=https://example.com/custom-style.css
```
> [!TIP]
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
This feature lets you customize the apps look with any CSS, giving full flexibility and allowing changes to take effect instantly at runtime without touching the code.
### Example Use Case
Let's say you want to change the font of our application to a custom font. You can create a custom CSS file with the following contents:
```css
@import url(https://fonts.bunny.net/css?family=Roboto:wght@400;700&display=swap);
:root {
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
}
```
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
> [!IMPORTANT]
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
> The app does **not provide separate light/dark themes**: outside a meeting it defaults to light, and in a room it switches to dark.
### Key Semantic Tokens
These control the main visual aspects of the interface:
| Category | Purpose | Example Token |
| ---------- | ------------------------------- | --------------------------- |
| Primary | Brand color, buttons, links | `--colors-primary` |
| Dark Mode | Primary color in room/dark mode | `--colors-primary-dark-500` |
| Greyscale | Text, backgrounds, borders | `--colors-greyscale-500` |
| Success | Success states | `--colors-success` |
| Error | Errors and destructive actions | `--colors-error` |
| Warning | Warnings and alerts | `--colors-warning` |
| Alert | Notification backgrounds | `--colors-alert` |
| Font Sans | Main UI font | `--fonts-sans` |
| Font Serif | Alternate/reading font | `--fonts-serif` |
| Font Mono | Code or technical font | `--fonts-mono` |
### Assets (Logo, Images)
You can override built-in assets (such as the logo or images) by mounting your own files into the container.
Simply bind-mount your custom assets to the following path inside the container:
```
/usr/share/nginx/html/assets
```
Any files you mount here will **override the defaults at runtime**.
For example, to replace the images used in the landing page carousel, provide your own versions with the same filenames and paths:
```
/usr/share/nginx/html/
└── assets/intro-slider/
├── 1.png
├── 2.png
├── 3.png
└── 4.png
```
## Build-Time Theming
Some settings cannot be applied at runtime and require rebuilding the Docker image.
One key example is the **application title and name**, controlled by the `VITE_APP_TITLE` build argument.
* **Default:** `La Suite Meet`
* **Override:** supply your own value at build time
```bash
docker build \
--build-arg VITE_APP_TITLE="My Custom Meet" \
-t my-org/meet:latest .
```
```dockerfile
# Dockerfile
ARG VITE_APP_TITLE="La Suite Meet"
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
```
For a real-world example, see how DINUM rebuilds the frontend to match their branding:
[DINUM Dockerfile](../docker/dinum-frontend/Dockerfile)
----
# **Footer Configuration**
The footer cannot be customized yet. This is a work in progress, and we welcome contributions — feel free to open a pull request if youd like to help add this feature.
You can enable the official French government footer by setting the environment variable `FRONTEND_USE_FRENCH_GOV_FOOTER` to true. This option is disabled (false) by default.
-11
View File
@@ -32,8 +32,6 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/cert
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token/introspect
OIDC_OP_URL=http://localhost:8083/realms/meet
OIDC_RP_CLIENT_ID=meet
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
@@ -47,9 +45,6 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
OIDC_RS_CLIENT_ID=meet
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
# Livekit Token settings
LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
@@ -70,9 +65,3 @@ ROOM_TELEPHONY_ENABLED=True
FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False
# External Applications
EXTERNAL_API_ENABLED=True
APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY=devKey
APPLICATION_BASE_URL=http://localhost:3000
+6 -2
View File
@@ -11,14 +11,18 @@ AWS_S3_SECRET_ACCESS_KEY="password"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
WHISPERX_API_KEY="your-secret-key"
WHISPERX_DEFAULT_LANGUAGE="fr"
LLM_BASE_URL="https://configure-your-url.com"
LLM_API_KEY="dev-apikey"
LLM_MODEL="albert-large"
LLM_MODEL="Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
WEBHOOK_API_TOKEN="secret"
WEBHOOK_URL="https://configure-your-url.com"
POSTHOG_API_KEY="your-posthog-key"
POSTHOG_ENABLED="False"
LANGFUSE_SECRET_KEY="your-secret-key"
LANGFUSE_PUBLIC_KEY="your-public-key"
LANGFUSE_HOST="https://cloud.langfuse.com"
LANFUSE_ENABLED="False"
-50
View File
@@ -1,50 +0,0 @@
# Django
DJANGO_ALLOWED_HOSTS=${MEET_HOST}
DJANGO_SECRET_KEY=<generate a secret key>
DJANGO_SETTINGS_MODULE=meet.settings
DJANGO_CONFIGURATION=Production
# Python
PYTHONPATH=/app
# Meet settings
# Mail
DJANGO_EMAIL_HOST=<smtp host>
DJANGO_EMAIL_HOST_USER=<smtp user>
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
DJANGO_EMAIL_PORT=<smtp port>
DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
# Backend url
MEET_BASE_URL="https://${MEET_HOST}"
# OIDC
OIDC_OP_JWKS_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID=<client_id>
OIDC_RP_CLIENT_SECRET=<client secret>
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
LOGIN_REDIRECT_URL=https://${MEET_HOST}
LOGIN_REDIRECT_URL_FAILURE=https://${MEET_HOST}
LOGOUT_REDIRECT_URL=https://${MEET_HOST}
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${MEET_HOST}"]
# Livekit Token settings
LIVEKIT_API_SECRET=<generate a secret key>
LIVEKIT_API_KEY=meet
LIVEKIT_API_URL=https://${LIVEKIT_HOST}
ALLOW_UNREGISTERED_ROOMS=False
-7
View File
@@ -1,7 +0,0 @@
MEET_HOST=meet.domain.tld
KEYCLOAK_HOST=id.domain.tld
LIVEKIT_HOST=livekit.domain.tld
BACKEND_INTERNAL_HOST=backend
FRONTEND_INTERNAL_HOST=frontend
LIVEKIT_INTERNAL_HOST=livekit
REALM_NAME=meet
-13
View File
@@ -1,13 +0,0 @@
# Postgresql db container configuration
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=<generate postgres password>
PGDATA=/var/lib/postgresql/data/pgdata
# Keycloak postgresql configuration
KC_DB=postgres
KC_DB_SCHEMA=public
KC_DB_URL_HOST=postgresql
KC_DB_NAME=${POSTGRES_DB}
KC_DB_USER=${POSTGRES_USER}
KC_DB_PASSWORD=${POSTGRES_PASSWORD}
-8
View File
@@ -1,8 +0,0 @@
# Keycloak admin user
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
# Keycloak configuration
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
KC_PROXY_HEADERS=xforwarded # in this example we are running behind an nginx proxy
KC_HTTP_ENABLED=true # in this example we are running behind an nginx proxy
-11
View File
@@ -1,11 +0,0 @@
# App database configuration
DB_HOST=postgresql
DB_NAME=meet
DB_USER=meet
DB_PASSWORD=<generate a secure password>
DB_PORT=5432
# Postgresql db container configuration
POSTGRES_DB=meet
POSTGRES_USER=meet
POSTGRES_PASSWORD=${DB_PASSWORD}
-6
View File
@@ -9,12 +9,6 @@
"matchManagers": ["pep621"],
"matchPackageNames": ["redis"]
},
{
"groupName": "allowed pylint versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
+1 -58
View File
@@ -29,72 +29,15 @@ logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
# Default Deepgram STT configuration
DEEPGRAM_STT_DEFAULTS = {
"model": "nova-3",
"language": "multi",
}
# Supported parameters for LiveKit's deepgram.STT() in streaming mode
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
# detect_language is NOT supported for real-time streaming
# Use language="multi" instead for automatic multilingual support
DEEPGRAM_STT_SUPPORTED_PARAMS = {
"model",
"language",
}
def _build_deepgram_stt_kwargs():
"""Build Deepgram STT kwargs from DEEPGRAM_STT_* environment variables.
Only parameters supported by LiveKit's deepgram.STT() are included.
Unsupported parameters are logged as warnings.
"""
stt_kwargs = DEEPGRAM_STT_DEFAULTS.copy()
# Scan environment variables for DEEPGRAM_STT_* pattern
for key, value in os.environ.items():
if key.startswith("DEEPGRAM_STT_"):
# Extract parameter name and convert to lowercase
param_name = key.replace("DEEPGRAM_STT_", "", 1).lower()
# Check if parameter is supported by LiveKit plugin
if param_name not in DEEPGRAM_STT_SUPPORTED_PARAMS:
supported = ", ".join(sorted(DEEPGRAM_STT_SUPPORTED_PARAMS))
logger.warning(
f"Ignoring unsupported Deepgram STT parameter: {param_name}. "
f"Supported parameters: {supported}"
)
continue
# Parse value type
value_lower = value.lower()
if value_lower in ("true", "false"):
# Boolean values
stt_kwargs[param_name] = value_lower == "true"
elif value.isdigit():
# Integer values
stt_kwargs[param_name] = int(value)
else:
# String values
stt_kwargs[param_name] = value
logger.info(f"Deepgram STT configuration: {stt_kwargs}")
return stt_kwargs
class Transcriber(Agent):
"""Create a transcription agent for a specific participant."""
def __init__(self, *, participant_identity: str):
"""Init transcription agent."""
# Build STT configuration from environment variables
stt_kwargs = _build_deepgram_stt_kwargs()
super().__init__(
instructions="not-needed",
stt=deepgram.STT(**stt_kwargs),
stt=deepgram.STT(),
)
self.participant_identity = participant_identity
+6 -6
View File
@@ -1,18 +1,18 @@
[project]
name = "agents"
version = "1.0.1"
version = "0.1.38"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.2.18",
"livekit-plugins-deepgram==1.2.18",
"livekit-plugins-silero==1.2.18",
"python-dotenv==1.2.1"
"livekit-agents==1.2.6",
"livekit-plugins-deepgram==1.2.6",
"livekit-plugins-silero==1.2.6",
"python-dotenv==1.1.1"
]
[project.optional-dependencies]
dev = [
"ruff==0.14.4",
"ruff==0.12.0",
]
[build-system]
+3 -147
View File
@@ -1,12 +1,9 @@
"""Admin classes and registrations for core app."""
from django import forms
from django.contrib import admin, messages
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from core.recording.event import notification
from . import models
@@ -111,27 +108,10 @@ class RoomAdmin(admin.ModelAdmin):
inlines = (ResourceAccessInline,)
search_fields = ["name", "slug", "=id"]
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
list_display = ["name", "slug", "access_level", "created_at"]
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_owner(self, obj):
"""Return the owner of the room for display in the admin list."""
owners = [
access
for access in obj.accesses.all()
if access.role == models.RoleChoices.OWNER
]
if not owners:
return _("No owner")
if len(owners) > 1:
return _("Multiple owners")
return str(owners[0].user)
class RecordingAccessInline(admin.TabularInline):
"""Inline admin class for recording accesses."""
@@ -140,76 +120,15 @@ class RecordingAccessInline(admin.TabularInline):
extra = 0
@admin.action(description=_("Resend notification to external service"))
def resend_notification(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Resend notification to external service for selected recordings."""
notification_service = notification.NotificationService()
processed = 0
skipped = 0
failed = 0
for recording in queryset:
if recording.is_expired:
skipped += 1
continue
try:
success = notification_service.notify_external_services(recording)
if success:
processed += 1
else:
failed += 1
modeladmin.message_user(
request,
_("Failed to notify for recording %(id)s") % {"id": recording.id},
level=messages.ERROR,
)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-except
failed += 1
modeladmin.message_user(
request,
_("Failed to notify for recording %(id)s: %(error)s")
% {"id": recording.id, "error": str(e)},
level=messages.ERROR,
)
if processed > 0:
modeladmin.message_user(
request,
_("Successfully sent notifications for %(count)s recording(s).")
% {"count": processed},
level=messages.SUCCESS,
)
if skipped > 0:
modeladmin.message_user(
request,
_("Skipped %(count)s expired recording(s).") % {"count": skipped},
level=messages.WARNING,
)
@admin.register(models.Recording)
class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
list_display = (
"id",
"status",
"mode",
"room",
"get_owner",
"created_at",
"worker_id",
)
list_display = ("id", "status", "room", "get_owner", "created_at", "worker_id")
list_filter = ["status", "room", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
actions = [resend_notification]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
@@ -231,66 +150,3 @@ class RecordingAdmin(admin.ModelAdmin):
return _("Multiple owners")
return str(owners[0].user)
class ApplicationDomainInline(admin.TabularInline):
"""Inline admin for managing allowed domains per application."""
model = models.ApplicationDomain
extra = 0
class ApplicationAdminForm(forms.ModelForm):
"""Custom form for Application admin with multi-select scopes."""
scopes = forms.MultipleChoiceField(
choices=models.ApplicationScope.choices,
widget=forms.CheckboxSelectMultiple,
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk and self.instance.scopes:
self.fields["scopes"].initial = self.instance.scopes
@admin.register(models.Application)
class ApplicationAdmin(admin.ModelAdmin):
"""Admin interface for managing applications and their permissions."""
form = ApplicationAdminForm
list_display = ("id", "name", "client_id", "get_scopes_display")
fields = [
"name",
"id",
"created_at",
"updated_at",
"scopes",
"client_id",
"client_secret",
]
readonly_fields = ["id", "created_at", "updated_at"]
inlines = [ApplicationDomainInline]
def get_readonly_fields(self, request, obj=None):
"""Make client_id and client_secret readonly after creation."""
if obj: # Editing existing object
return self.readonly_fields + ["client_id", "client_secret"]
return self.readonly_fields
def get_fields(self, request, obj=None):
"""Hide client_secret after creation."""
fields = super().get_fields(request, obj)
if obj:
return [f for f in fields if f != "client_secret"]
return fields
def get_scopes_display(self, obj):
"""Display scopes in list view."""
if obj.scopes:
return ", ".join(obj.scopes)
return _("No scopes")
get_scopes_display.short_description = _("Scopes")
-2
View File
@@ -159,8 +159,6 @@ class RoomSerializer(serializers.ModelSerializer):
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
)
else:
del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
+1 -1
View File
@@ -30,7 +30,7 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
raise exceptions.AuthenticationFailed("Token missing user identity")
try:
user = UserModel.objects.get(sub=user_id)
user = UserModel.objects.get(id=user_id)
except UserModel.DoesNotExist:
user = AnonymousUser()
@@ -1 +0,0 @@
"""Meet core external API endpoints"""
@@ -1,167 +0,0 @@
"""Authentication Backends for external application to the Meet core app."""
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import jwt as pyJwt
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from rest_framework import authentication, exceptions
User = get_user_model()
logger = logging.getLogger(__name__)
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
"""JWT authentication for application-delegated API access.
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
def authenticate(self, request):
"""Extract and validate JWT from Authorization header.
Returns:
Tuple of (user, payload) if authentication successful, None otherwise
"""
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != b"bearer":
# Defer to next authentication backend
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
token = auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
return self.authenticate_credentials(token)
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.
If token is invalid, defer to next authentication backend.
Args:
token: JWT token string
Returns:
Tuple of (user, payload)
Raises:
AuthenticationFailed: If token is expired, or user not found
"""
# Decode and validate JWT
try:
payload = pyJwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
return None
user_id = payload.get("user_id")
client_id = payload.get("client_id")
is_delegated = payload.get("delegated", False)
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not client_id:
logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not is_delegated:
logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return (user, payload)
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval."""
def get_or_create_user(self, access_token, id_token, payload):
"""Get or create user from OIDC token claims.
Despite the LaSuiteBackend's method name suggesting "get_or_create",
its implementation only performs a GET operation.
Create new user from the sub claim.
Args:
access_token: The access token string
id_token: The ID token string (unused)
payload: Token payload dict (unused)
Returns:
User instance
Raises:
SuspiciousOperation: If user info validation fails
"""
sub = payload.get("sub")
if sub is None:
message = "User info contained no recognizable user identification"
logger.debug(message)
raise SuspiciousOperation(message)
user = self.get_user(access_token, id_token, payload)
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
return user
def create_user(self, sub):
"""Create new user from subject claim.
Args:
sub: Subject identifier from token
Returns:
Newly created User instance
"""
user = self.UserModel(sub=sub)
user.set_unusable_password()
user.save()
return user
@@ -1,84 +0,0 @@
"""Permission handlers for application-delegated API access."""
import logging
from typing import Dict
from django.conf import settings
from rest_framework import exceptions, permissions
from .. import models
logger = logging.getLogger(__name__)
class BaseScopePermission(permissions.BasePermission):
"""Base class for scope-based permission checking.
Subclasses must define `scope_map` attribute mapping actions to required scopes.
"""
scope_map: Dict[str, str] = {}
def has_permission(self, request, view):
"""Check if the JWT token contains the required scope for this action.
Args:
request: DRF request object with authenticated user
view: ViewSet instance
Returns:
bool: True if permission granted
Raises:
PermissionDenied: If required scope is missing from token
"""
# Get the current action (e.g., 'list', 'create')
action = getattr(view, "action", None)
if not action:
raise exceptions.PermissionDenied(
"Insufficient permissions. Unknown action."
)
required_scope = self.scope_map.get(action)
if not required_scope:
# Action not in scope_map, deny by default
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
)
token_payload = request.auth
token_scopes = token_payload.get("scope")
if not token_scopes:
raise exceptions.PermissionDenied("Insufficient permissions.")
# Ensure scopes is a list (handle both list and space-separated string)
if isinstance(token_scopes, str):
token_scopes = token_scopes.split()
if settings.OIDC_RS_SCOPES_PREFIX:
token_scopes = [
scope.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
for scope in token_scopes
]
if required_scope not in token_scopes:
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
)
return True
class HasRequiredRoomScope(BaseScopePermission):
"""Permission class for Room-related operations."""
scope_map = {
"list": models.ApplicationScope.ROOMS_LIST,
"retrieve": models.ApplicationScope.ROOMS_RETRIEVE,
"create": models.ApplicationScope.ROOMS_CREATE,
"update": models.ApplicationScope.ROOMS_UPDATE,
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
"destroy": models.ApplicationScope.ROOMS_DELETE,
}
@@ -1,73 +0,0 @@
"""Serializers for the external API of the Meet core app."""
# pylint: disable=abstract-method
from django.conf import settings
from rest_framework import serializers
from core import models, utils
from core.api.serializers import BaseValidationOnlySerializer
OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
class ApplicationJwtSerializer(BaseValidationOnlySerializer):
"""Validate OAuth2 JWT token request data."""
client_id = serializers.CharField(write_only=True)
client_secret = serializers.CharField(write_only=True)
grant_type = serializers.ChoiceField(choices=[OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS])
scope = serializers.CharField(write_only=True)
class RoomSerializer(serializers.ModelSerializer):
"""External API serializer for room data exposed to applications.
Provides limited, safe room information for third-party integrations:
- Secure defaults for room creation (trusted access level)
- Computed fields (url, telephony) for external consumption
- Filtered data appropriate for delegation scenarios
- Tracks creation source for auditing
Intentionally exposes minimal information to external applications,
following the principle of least privilege.
"""
class Meta:
model = models.Room
fields = ["id", "name", "slug", "pin_code", "access_level"]
read_only_fields = ["id", "name", "slug", "pin_code", "access_level"]
def to_representation(self, instance):
"""Enrich response with application-specific computed fields."""
output = super().to_representation(instance)
request = self.context.get("request")
pin_code = output.pop("pin_code", None)
if not request:
return output
# Add room URL for direct access
if settings.APPLICATION_BASE_URL:
output["url"] = f"{settings.APPLICATION_BASE_URL}/{instance.slug}"
# Add telephony information if enabled
if settings.ROOM_TELEPHONY_ENABLED:
output["telephony"] = {
"enabled": True,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER,
"pin_code": pin_code,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
}
return output
def create(self, validated_data):
"""Create room with secure defaults for application delegation."""
# Set secure defaults
validated_data["name"] = utils.generate_room_slug()
validated_data["access_level"] = models.RoomAccessLevel.TRUSTED
return super().create(validated_data)
-198
View File
@@ -1,198 +0,0 @@
"""External API endpoints"""
from datetime import datetime, timedelta, timezone
from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
import jwt
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import decorators, mixins, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
response as drf_response,
)
from rest_framework import (
status as drf_status,
)
from core import api, models
from . import authentication, permissions, serializers
logger = getLogger(__name__)
class ApplicationViewSet(viewsets.GenericViewSet):
"""API endpoints for application authentication and token generation."""
@decorators.action(
detail=False,
methods=["post"],
url_path="token",
url_name="token",
)
def generate_jwt_access_token(self, request, *args, **kwargs):
"""Generate JWT access token for application delegation.
Validates application credentials and generates a JWT token scoped
to a specific user email, allowing the application to act on behalf
of that user.
Note: The 'scope' parameter accepts an email address to identify the user
being delegated. This design allows applications to obtain user-scoped tokens
for delegation purposes. The scope field is intentionally generic and can be
extended to support other values in the future.
Reference: https://stackoverflow.com/a/27711422
"""
serializer = serializers.ApplicationJwtSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
client_id = serializer.validated_data["client_id"]
client_secret = serializer.validated_data["client_secret"]
try:
application = models.Application.objects.get(client_id=client_id)
except models.Application.DoesNotExist as e:
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
if not application.active:
raise drf_exceptions.AuthenticationFailed("Application is inactive")
if not check_password(client_secret, application.client_secret):
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
email = serializer.validated_data["scope"]
try:
validate_email(email)
except ValidationError:
return drf_response.Response(
{
"error": "Scope should be a valid email address.",
},
status=drf_status.HTTP_400_BAD_REQUEST,
)
if not application.can_delegate_email(email):
logger.warning(
"Application %s denied delegation for %s",
application.client_id,
email,
)
return drf_response.Response(
{
"error": "This application is not authorized for this email domain.",
},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
user = models.User.objects.get(email=email)
except models.User.DoesNotExist as e:
raise drf_exceptions.NotFound(
{
"error": "User not found.",
}
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": client_id,
"scope": scope,
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
return drf_response.Response(
{
"access_token": token,
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": scope,
},
status=drf_status.HTTP_200_OK,
)
class RoomViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""Application-delegated API for room management.
Provides JWT-authenticated access to room operations for external applications
acting on behalf of users. All operations are scope-based and filtered to the
authenticated user's accessible rooms.
Supported operations:
- list: List rooms the user has access to (requires 'rooms:list' scope)
- retrieve: Get room details (requires 'rooms:retrieve' scope)
- create: Create a new room owned by the user (requires 'rooms:create' scope)
"""
authentication_classes = [
authentication.ApplicationJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
def list(self, request, *args, **kwargs):
"""Limit listed rooms to the ones related to the authenticated user."""
user = self.request.user
if user.is_authenticated:
queryset = (
self.filter_queryset(self.get_queryset()).filter(users=user).distinct()
)
else:
queryset = self.get_queryset().none()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room."""
room = serializer.save()
models.ResourceAccess.objects.create(
resource=room,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
# Log for auditing
logger.info(
"Room created via application: room_id=%s, user_id=%s, client_id=%s",
room.id,
self.request.user.id,
getattr(self.request.auth, "client_id", "unknown"),
)
+1 -37
View File
@@ -9,7 +9,7 @@ from django.utils.text import slugify
import factory.fuzzy
from faker import Faker
from core import models, utils
from core import models
fake = Faker()
@@ -117,39 +117,3 @@ class TeamRecordingAccessFactory(factory.django.DjangoModelFactory):
recording = factory.SubFactory(RecordingFactory)
team = factory.Sequence(lambda n: f"team{n}")
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
class ApplicationFactory(factory.django.DjangoModelFactory):
"""Create fake applications for testing."""
class Meta:
model = models.Application
name = factory.Faker("company")
active = True
client_id = factory.LazyFunction(utils.generate_client_id)
client_secret = factory.LazyFunction(utils.generate_client_secret)
scopes = []
class Params:
"""Factory traits for common application configurations."""
with_all_scopes = factory.Trait(
scopes=[
models.ApplicationScope.ROOMS_LIST,
models.ApplicationScope.ROOMS_RETRIEVE,
models.ApplicationScope.ROOMS_CREATE,
models.ApplicationScope.ROOMS_UPDATE,
models.ApplicationScope.ROOMS_DELETE,
]
)
class ApplicationDomainFactory(factory.django.DjangoModelFactory):
"""Create fake application domains for testing."""
class Meta:
model = models.ApplicationDomain
domain = factory.Faker("domain_name")
application = factory.SubFactory(ApplicationFactory)
-43
View File
@@ -1,43 +0,0 @@
"""
Core application fields
"""
from logging import getLogger
from django.contrib.auth.hashers import identify_hasher, make_password
from django.db import models
logger = getLogger(__name__)
class SecretField(models.CharField):
"""CharField that automatically hashes secrets before saving.
Use for API keys, client secrets, or tokens that should never be stored
in plain text. Already-hashed values are preserved to prevent double-hashing.
Inspired by: https://github.com/django-oauth-toolkit/django-oauth-toolkit
"""
def pre_save(self, model_instance, add):
"""Hash the secret if not already hashed, otherwise preserve it."""
secret = getattr(model_instance, self.attname)
try:
hasher = identify_hasher(secret)
logger.debug(
"%s: %s is already hashed with %s.",
model_instance,
self.attname,
hasher,
)
except ValueError:
logger.debug(
"%s: %s is not hashed; hashing it now.", model_instance, self.attname
)
hashed_secret = make_password(secret)
setattr(model_instance, self.attname, hashed_secret)
return hashed_secret
return super().pre_save(model_instance, add)
@@ -1,52 +0,0 @@
# Generated by Django 5.2.6 on 2025-10-02 20:55
import core.utils
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0014_room_pin_code'),
]
operations = [
migrations.CreateModel(
name='Application',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('name', models.CharField(help_text='Descriptive name for this application.', max_length=255, verbose_name='Application name')),
('active', models.BooleanField(default=True)),
('client_id', models.CharField(default=core.utils.generate_client_id, max_length=100, unique=True)),
('client_secret', core.fields.SecretField(blank=True, default=core.utils.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255)),
('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms')], max_length=50), blank=True, default=list, size=None)),
],
options={
'verbose_name': 'Application',
'verbose_name_plural': 'Applications',
'db_table': 'meet_application',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='ApplicationDomain',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('domain', models.CharField(help_text='Email domain this application can act on behalf of.', max_length=253, validators=[django.core.validators.DomainNameValidator(accept_idna=False, message='Enter a valid domain')], verbose_name='Domain')),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_domains', to='core.application')),
],
options={
'verbose_name': 'Application domain',
'verbose_name_plural': 'Application domains',
'db_table': 'meet_application_domain',
'ordering': ('domain',),
'unique_together': {('application', 'domain')},
},
),
]
-101
View File
@@ -11,7 +11,6 @@ from typing import List, Optional
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
@@ -19,10 +18,8 @@ from django.utils import timezone
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
from lasuite.tools.email import get_domain_from_email
from timezone_field import TimeZoneField
from . import fields, utils
from .recording.enums import FileExtension
logger = getLogger(__name__)
@@ -720,101 +717,3 @@ class RecordingAccess(BaseAccess):
Compute and return abilities for a given user on the recording access.
"""
return self._get_abilities(self.recording, user)
class ApplicationScope(models.TextChoices):
"""Available permission scopes for application operations."""
ROOMS_CREATE = "rooms:create", _("Create rooms")
ROOMS_LIST = "rooms:list", _("List rooms")
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
ROOMS_UPDATE = "rooms:update", _("Update rooms")
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
class Application(BaseModel):
"""External application for API authentication and authorization.
Represents a third-party integration or automated system that accesses
the API using OAuth2-style client credentials (client_id/client_secret).
Supports scoped permissions and optional domain restrictions for delegation.
"""
name = models.CharField(
max_length=255,
verbose_name=_("Application name"),
help_text=_("Descriptive name for this application."),
)
active = models.BooleanField(default=True)
client_id = models.CharField(
max_length=100, unique=True, default=utils.generate_client_id
)
client_secret = fields.SecretField(
max_length=255,
blank=True,
default=utils.generate_client_secret,
help_text=_("Hashed on Save. Copy it now if this is a new secret."),
)
scopes = ArrayField(
models.CharField(max_length=50, choices=ApplicationScope.choices),
default=list,
blank=True,
)
class Meta:
db_table = "meet_application"
ordering = ("-created_at",)
verbose_name = _("Application")
verbose_name_plural = _("Applications")
def __str__(self):
return f"{self.name!s}"
def can_delegate_email(self, email):
"""Check if this application can delegate the given email."""
if not self.allowed_domains.exists():
return True # No domain restrictions
domain = get_domain_from_email(email)
return self.allowed_domains.filter(domain__iexact=domain).exists()
class ApplicationDomain(BaseModel):
"""Domain authorized for application delegation."""
domain = models.CharField(
max_length=253, # Max domain length per RFC 1035
validators=[
validators.DomainNameValidator(
accept_idna=False,
message=_("Enter a valid domain"),
)
],
verbose_name=_("Domain"),
help_text=_("Email domain this application can act on behalf of."),
)
application = models.ForeignKey(
"Application",
on_delete=models.CASCADE,
related_name="allowed_domains",
)
class Meta:
db_table = "meet_application_domain"
ordering = ("domain",)
verbose_name = _("Application domain")
verbose_name_plural = _("Application domains")
unique_together = [("application", "domain")]
def __str__(self):
"""Return string representation of the domain."""
return self.domain
def save(self, *args, **kwargs):
"""Save the domain after normalizing to lowercase."""
self.domain = self.domain.lower().strip()
super().save(*args, **kwargs)
@@ -131,8 +131,8 @@ class NotificationService:
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
payload = {
"owner_id": str(owner_access.user.id),
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
@@ -158,9 +158,9 @@ class NotificationService:
timeout=30,
)
response.raise_for_status()
except requests.RequestException as exc:
except requests.HTTPError as exc:
logger.exception(
"Summary service error for recording %s. URL: %s. Exception: %s",
"Summary service HTTP error for recording %s. URL: %s. Exception: %s",
recording.id,
settings.SUMMARY_SERVICE_ENDPOINT,
exc,
@@ -2,7 +2,6 @@
# pylint: disable=no-member
import re
import uuid
from enum import Enum
from logging import getLogger
@@ -93,17 +92,6 @@ class LiveKitEventsService:
self.telephony_service = TelephonyService()
self.recording_events = RecordingEventsService()
self._filter_regex = None
if settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX:
try:
self._filter_regex = re.compile(
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX
)
except re.error:
logger.exception(
"Invalid LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX. Webhook filtering disabled."
)
def receive(self, request):
"""Process webhook and route to appropriate handler."""
@@ -118,10 +106,6 @@ class LiveKitEventsService:
except Exception as e:
raise InvalidPayloadError("Invalid webhook payload") from e
if self._filter_regex and not self._filter_regex.search(data.room.name):
logger.info("Filtered webhook event for room '%s'", data.room.name)
return
try:
webhook_type = LiveKitWebhookEventType(data.event)
except ValueError as e:
@@ -345,9 +345,7 @@ def test_authentication_getter_existing_user_change_fields(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
# Note: .save() triggers uniqueness validation queries for unique fields,
# adding extra SELECT queries before the UPDATE (e.g., checking unique=True on 'sub')
with django_assert_num_queries(3):
with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -32,6 +32,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -51,6 +52,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -69,6 +71,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -85,6 +88,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -101,6 +105,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -342,6 +347,7 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -343,99 +343,3 @@ def test_receive_unsupported_event(mock_receive, service):
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
):
service.receive(mock_request)
@mock.patch.object(api.WebhookReceiver, "receive")
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
def test_receive_no_filter_processes_all_events(
mock_handle_room_started, mock_receive, mock_livekit_config, settings
):
"""Should process all events when filter regex is not configured."""
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = None
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
mock_data = mock.MagicMock()
mock_data.room.name = "!JIfCxVLcKKkWrmVBOb:your-domain.com"
mock_data.event = "room_started"
mock_receive.return_value = mock_data
service = LiveKitEventsService()
service.receive(mock_request)
mock_handle_room_started.assert_called_once()
@mock.patch.object(api.WebhookReceiver, "receive")
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
def test_receive_invalid_filter_regex_processes_all_events(
mock_handle_room_started, mock_receive, mock_livekit_config, settings
):
"""Should process all events when filter regex is invalid (fail-safe)."""
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = "(abc"
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
mock_data = mock.MagicMock()
mock_data.room.name = "!JIfCxVLcKKkWrmVBOb:your-domain.com"
mock_data.event = "room_started"
mock_receive.return_value = mock_data
service = LiveKitEventsService()
service.receive(mock_request)
mock_handle_room_started.assert_called_once()
@mock.patch.object(api.WebhookReceiver, "receive")
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
def test_receive_filter_drops_non_matching_events(
mock_handle_room_started, mock_receive, mock_livekit_config, settings
):
"""Should drop events when room name does not match filter regex."""
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = (
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
)
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
mock_data = mock.MagicMock()
mock_data.room.name = "!JIfCxVLcKKkWrmVBOb:your-domain.com"
mock_data.event = "room_started"
mock_receive.return_value = mock_data
service = LiveKitEventsService()
service.receive(mock_request)
mock_handle_room_started.assert_not_called()
@mock.patch.object(api.WebhookReceiver, "receive")
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
def test_receive_filter_processes_matching_events(
mock_handle_room_started, mock_receive, mock_livekit_config, settings
):
"""Should process events when room name matches filter regex."""
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = (
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
)
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
mock_data.event = "room_started"
mock_receive.return_value = mock_data
service = LiveKitEventsService()
service.receive(mock_request)
mock_handle_room_started.assert_called_once()
@@ -1,569 +0,0 @@
"""
Tests for external API /room endpoint
"""
# pylint: disable=W0621
from datetime import datetime, timedelta, timezone
from django.conf import settings
import jwt
import pytest
import responses
from rest_framework.test import APIClient
from core.factories import (
RoomFactory,
UserFactory,
)
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
pytestmark = pytest.mark.django_db
def generate_test_token(user, scopes):
"""Generate a valid JWT token for testing."""
now = datetime.now(timezone.utc)
scope_string = " ".join(scopes)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": "test-client-id",
"scope": scope_string,
"user_id": str(user.id),
"delegated": True,
}
return jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
def test_api_rooms_list_requires_authentication():
"""Listing rooms without authentication should return 401."""
client = APIClient()
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
def test_api_rooms_list_with_valid_token(settings):
"""Listing rooms with valid token should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Generate valid token
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 1
assert response.data["results"][0]["id"] == str(room.id)
def test_api_rooms_list_with_expired_token(settings):
"""Listing rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
@responses.activate
def test_api_rooms_list_with_invalid_token(settings):
"""Listing rooms with invalid token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": False,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.get("/external-api/v1.0/rooms/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
def test_api_rooms_list_missing_scope(settings):
"""Listing rooms without required scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without ROOMS_LIST scope
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
def test_api_rooms_list_filters_by_user(settings):
"""List should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
user2 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 2
returned_ids = [r["id"] for r in response.data["results"]]
assert str(room1.id) in returned_ids
assert str(room3.id) in returned_ids
assert str(room2.id) not in returned_ids
def test_api_rooms_retrieve_requires_scope(settings):
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Token without ROOMS_RETRIEVE scope
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:retrieve" in str(
response.data
)
def test_api_rooms_retrieve_success(settings):
"""Retrieving a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = "http://your-application.com"
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "US"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
assert response.data == {
"id": str(room.id),
"name": room.name,
"slug": room.slug,
"access_level": str(room.access_level),
"url": f"http://your-application.com/{room.slug}",
"telephony": {
"enabled": True,
"phone_number": "+1-555-0100",
"pin_code": room.pin_code,
"default_country": "US",
},
}
def test_api_rooms_create_requires_scope(settings):
"""Creating a room requires ROOMS_CREATE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without ROOMS_CREATE scope
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 403
assert "Insufficient permissions. Required scope: rooms:create" in str(
response.data
)
def test_api_rooms_create_success(settings):
"""Creating a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 201
assert "id" in response.data
assert "slug" in response.data
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert room.access_level == "trusted"
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = None
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
assert "url" not in response.data
assert response.data["id"] == str(room.id)
def test_api_rooms_response_no_telephony(settings):
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.ROOM_TELEPHONY_ENABLED = False
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
assert "telephony" not in response.data
assert response.data["id"] == str(room.id)
def test_api_rooms_token_without_delegated_flag(settings):
"""Token without delegated flag should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "test-client",
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": False, # Not delegated
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "Invalid token type." in str(response.data)
def test_api_rooms_token_missing_client_id(settings):
"""Token without client_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
# Missing client_id
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "Invalid token claims." in str(response.data)
@responses.activate
def test_resource_server_creates_user_on_first_authentication(settings):
"""New user should be created during first authentication.
Verifies that the ResourceServerBackend.get_or_create_user() creates a user
in the database when authenticating with a token from an unknown subject (sub).
This tests the user creation workflow during the OIDC introspection process.
"""
with pytest.raises(
User.DoesNotExist,
match="User matching query does not exist.",
):
User.objects.get(sub="very-specific-sub")
assert (
settings.OIDC_RS_BACKEND_CLASS
== "core.external_api.authentication.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 0
db_user = User.objects.get(sub="very-specific-sub")
assert db_user is not None
assert db_user.email is None
@responses.activate
def test_resource_server_skips_user_creation_when_auto_creation_disabled(settings):
"""Verify that ResourceServerBackend respects the user auto-creation setting.
This ensures that the OIDC introspection process respects the configuration flag
that controls whether new users should be automatically provisioned during
authentication, preventing unwanted user proliferation when auto-creation is
explicitly disabled.
"""
settings.OIDC_CREATE_USER = False
with pytest.raises(
User.DoesNotExist,
match="User matching query does not exist.",
):
User.objects.get(sub="very-specific-sub")
assert (
settings.OIDC_RS_BACKEND_CLASS
== "core.external_api.authentication.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet rooms:list",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
@responses.activate
def test_resource_server_authentication_successful(settings):
"""Authenticated requests should be processed and user-specific data is returned.
Verifies that once a user is authenticated via OIDC token introspection,
the API correctly identifies the user and returns only data accessible to that user
(e.g., rooms with appropriate access levels).
"""
user = UserFactory(sub="very-specific-sub")
other_user = UserFactory()
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room_user_accesses = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED, users=[user]
)
RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[other_user])
assert (
settings.OIDC_RS_BACKEND_CLASS
== "core.external_api.authentication.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 1
expected_ids = {
str(room_user_accesses.id),
}
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@responses.activate
def test_resource_server_denies_access_with_insufficient_scopes(settings):
"""Requests should be denied when the token lacks required scopes.
Verifies that the ResourceServerBackend validates token scopes during introspection
and returns 403 Forbidden when the token is missing required scopes for the endpoint.
"""
assert (
settings.OIDC_RS_BACKEND_CLASS
== "core.external_api.authentication.ResourceServerBackend"
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet", # missing rooms:list scope
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
@@ -1,275 +0,0 @@
"""
Tests for external API /token endpoint
"""
# pylint: disable=W0621
import jwt
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from core.factories import (
ApplicationDomainFactory,
ApplicationFactory,
UserFactory,
)
from core.models import ApplicationScope
pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
# Store plain secret before it's hashed
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 200
assert "access_token" in response.data
response.data.pop("access_token")
assert response.data == {
"token_type": "Bearer",
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": "rooms:list rooms:create",
}
def test_api_applications_generate_token_invalid_client_id():
"""Invalid client_id should return 401."""
user = UserFactory(email="user@example.com")
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": "invalid-client-id",
"client_secret": "some-secret",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_invalid_client_secret():
"""Invalid client_secret should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(active=True)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_inactive_application():
"""Inactive application should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(active=False)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Application is inactive" in str(response.data)
def test_api_applications_generate_token_invalid_email_format():
"""Invalid email format should return 400."""
application = ApplicationFactory(active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "not-an-email",
},
format="json",
)
assert response.status_code == 400
assert "scope should be a valid email address." in str(response.data).lower()
def test_api_applications_generate_token_domain_not_authorized():
"""Application without domain authorization should return 403."""
user = UserFactory(email="user@denied.com")
application = ApplicationFactory(active=True)
ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 403
assert "not authorized for this email domain" in str(response.data)
def test_api_applications_generate_token_domain_authorized(settings):
"""Application with domain authorization should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@allowed.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 200
assert "access_token" in response.data
def test_api_applications_generate_token_user_not_found():
"""Non-existent user should return 404."""
application = ApplicationFactory(active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "nonexistent@example.com",
},
format="json",
)
assert response.status_code == 404
assert "User not found" in str(response.data)
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
@@ -1,331 +0,0 @@
"""
Unit tests for the Application and ApplicationDomain models
"""
# pylint: disable=W0613
from unittest import mock
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
import pytest
from core.factories import ApplicationDomainFactory, ApplicationFactory
from core.models import Application, ApplicationDomain, ApplicationScope
pytestmark = pytest.mark.django_db
# Application Model Tests
def test_models_application_str():
"""The str representation should be the name."""
application = ApplicationFactory(name="My Integration")
assert str(application) == "My Integration"
def test_models_application_name_maxlength():
"""The name field should be at most 255 characters."""
ApplicationFactory(name="a" * 255)
with pytest.raises(ValidationError) as excinfo:
ApplicationFactory(name="a" * 256)
assert "Ensure this value has at most 255 characters (it has 256)." in str(
excinfo.value
)
def test_models_application_active_default():
"""An application should be active by default."""
application = Application.objects.create(name="Test App")
assert application.active is True
def test_models_application_scopes_default():
"""Scopes should default to empty list."""
application = Application.objects.create(name="Test App")
assert application.scopes == []
def test_models_application_client_id_auto_generated():
"""Client ID should be automatically generated on creation."""
application = ApplicationFactory()
assert application.client_id is not None
assert len(application.client_id) > 0
def test_models_application_client_id_unique():
"""Client IDs should be unique."""
app1 = ApplicationFactory()
with pytest.raises(ValidationError) as excinfo:
ApplicationFactory(client_id=app1.client_id)
assert "Application with this Client id already exists." in str(excinfo.value)
def test_models_application_client_id_length(settings):
"""Client ID should match configured length."""
app1 = ApplicationFactory()
assert len(app1.client_id) == 40 # default value
settings.APPLICATION_CLIENT_ID_LENGTH = 20
app2 = ApplicationFactory()
assert len(app2.client_id) == 20
def test_models_application_client_secret_auto_generated():
"""Client secret should be automatically generated and hashed on creation."""
application = ApplicationFactory()
assert application.client_secret is not None
assert len(application.client_secret) > 0
def test_models_application_client_secret_hashed_on_save():
"""Client secret should be hashed when saved."""
plain_secret = "my-plain-secret"
with mock.patch(
"core.models.utils.generate_client_secret", return_value=plain_secret
):
application = ApplicationFactory(client_secret=plain_secret)
# Secret should be hashed, not plain
assert application.client_secret != plain_secret
# Should verify with check_password
assert check_password(plain_secret, application.client_secret) is True
def test_models_application_client_secret_preserves_existing_hash():
"""Re-saving should not re-hash an already hashed secret."""
application = ApplicationFactory()
original_hash = application.client_secret
# Update another field and save
application.name = "Updated Name"
application.save()
# Hash should remain unchanged
assert application.client_secret == original_hash
def test_models_application_updates_preserve_client_id():
"""Application updates should preserve existing client_id."""
application = ApplicationFactory()
original_client_id = application.client_id
application.name = "Updated Name"
application.save()
assert application.client_id == original_client_id
def test_models_application_scopes_valid_choices():
"""Only valid scope choices should be accepted."""
application = ApplicationFactory(
scopes=[
ApplicationScope.ROOMS_LIST,
ApplicationScope.ROOMS_CREATE,
ApplicationScope.ROOMS_RETRIEVE,
]
)
assert len(application.scopes) == 3
assert ApplicationScope.ROOMS_LIST in application.scopes
def test_models_application_scopes_invalid_choice():
"""Invalid scope choices should raise validation error."""
with pytest.raises(ValidationError) as excinfo:
ApplicationFactory(scopes=["invalid:scope"])
assert "is not a valid choice" in str(excinfo.value)
def test_models_application_can_delegate_email_no_restrictions():
"""Application with no domain restrictions can delegate any email."""
application = ApplicationFactory()
assert application.can_delegate_email("user@example.com") is True
assert application.can_delegate_email("admin@anotherdomain.org") is True
def test_models_application_can_delegate_email_allowed_domain():
"""Application can delegate email from allowed domain."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
assert application.can_delegate_email("user@example.com") is True
def test_models_application_can_delegate_email_denied_domain():
"""Application cannot delegate email from non-allowed domain."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
assert application.can_delegate_email("user@other.com") is False
def test_models_application_can_delegate_email_case_insensitive():
"""Domain matching should be case-insensitive."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
assert application.can_delegate_email("user@EXAMPLE.COM") is True
assert application.can_delegate_email("user@Example.Com") is True
def test_models_application_can_delegate_email_multiple_domains():
"""Application with multiple allowed domains should check all."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
ApplicationDomainFactory(application=application, domain="other.org")
assert application.can_delegate_email("user@example.com") is True
assert application.can_delegate_email("admin@other.org") is True
assert application.can_delegate_email("test@denied.com") is False
# ApplicationDomain Model Tests
def test_models_application_domain_str():
"""The str representation should be the domain."""
domain = ApplicationDomainFactory(domain="example.com")
assert str(domain) == "example.com"
def test_models_application_domain_ordering():
"""Domains should be returned ordered by domain name."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="zulu.com")
ApplicationDomainFactory(application=application, domain="alpha.com")
ApplicationDomainFactory(application=application, domain="beta.com")
domains = ApplicationDomain.objects.all()
assert domains[0].domain == "alpha.com"
assert domains[1].domain == "beta.com"
assert domains[2].domain == "zulu.com"
@pytest.mark.parametrize(
"valid_domain",
[
"example.com",
"sub.example.com",
"deep.sub.example.com",
"example-with-dash.com",
"123.example.com",
],
)
def test_models_application_domain_valid_domain(valid_domain):
"""Valid domain names should be accepted."""
ApplicationDomainFactory(domain=valid_domain)
@pytest.mark.parametrize(
"invalid_domain",
[
"not a domain",
"example..com",
"-example.com",
"example-.com",
"example.com-",
],
)
def test_models_application_domain_invalid_domain(invalid_domain):
"""Invalid domain names should raise validation error."""
with pytest.raises(ValidationError):
ApplicationDomainFactory(domain=invalid_domain)
def test_models_application_domain_lowercase_on_save():
"""Domain should be normalized to lowercase on save."""
domain = ApplicationDomainFactory(domain="EXAMPLE.COM")
assert domain.domain == "example.com"
def test_models_application_domain_strip_whitespace_on_save():
"""Domain should strip whitespace on save."""
domain = ApplicationDomainFactory(domain=" example.com ")
assert domain.domain == "example.com"
def test_models_application_domain_combined_normalization():
"""Domain should strip and lowercase in one operation."""
domain = ApplicationDomainFactory(domain=" EXAMPLE.COM ")
assert domain.domain == "example.com"
def test_models_application_domain_unique_together():
"""Same domain cannot be added twice to same application."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
with pytest.raises(ValidationError) as excinfo:
ApplicationDomainFactory(application=application, domain="example.com")
assert "Application domain with this Application and Domain already exists." in str(
excinfo.value
)
def test_models_application_domain_same_domain_different_apps():
"""Same domain can belong to different applications."""
app1 = ApplicationFactory()
app2 = ApplicationFactory()
ApplicationDomainFactory(application=app1, domain="example.com")
ApplicationDomainFactory(application=app2, domain="example.com")
assert app1.allowed_domains.count() == 1
assert app2.allowed_domains.count() == 1
def test_models_application_domain_cascade_delete():
"""Deleting application should delete its domains."""
application = ApplicationFactory()
ApplicationDomainFactory(application=application, domain="example.com")
ApplicationDomainFactory(application=application, domain="other.com")
assert ApplicationDomain.objects.count() == 2
application.delete()
assert ApplicationDomain.objects.count() == 0
def test_models_application_domain_related_name():
"""Domains should be accessible via application.allowed_domains."""
application = ApplicationFactory()
domain1 = ApplicationDomainFactory(application=application, domain="example.com")
domain2 = ApplicationDomainFactory(application=application, domain="other.com")
assert list(application.allowed_domains.all()) == [domain1, domain2]
def test_models_application_domain_filters_delegation():
"""Adding/removing domains should affect can_delegate_email."""
application = ApplicationFactory()
# No restrictions initially
assert application.can_delegate_email("user@example.com") is True
# Add domain restriction
domain = ApplicationDomainFactory(application=application, domain="example.com")
assert application.can_delegate_email("user@example.com") is True
assert application.can_delegate_email("user@other.com") is False
# Remove domain restriction
domain.delete()
assert application.can_delegate_email("user@other.com") is True
-27
View File
@@ -7,7 +7,6 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import get_frontend_configuration, viewsets
from core.external_api import viewsets as external_viewsets
# - Main endpoints
router = DefaultRouter()
@@ -18,20 +17,6 @@ router.register(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
)
# - External API
external_router = DefaultRouter()
external_router.register(
"application",
external_viewsets.ApplicationViewSet,
basename="external_application",
)
external_router.register(
"rooms",
external_viewsets.RoomViewSet,
basename="external_room",
)
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
@@ -44,15 +29,3 @@ urlpatterns = [
),
),
]
if settings.EXTERNAL_API_ENABLED:
urlpatterns.append(
path(
f"external-api/{settings.EXTERNAL_API_VERSION}/",
include(
[
*external_router.urls,
]
),
)
)
-52
View File
@@ -8,8 +8,6 @@ Utils functions used in the core app
import hashlib
import json
import random
import secrets
import string
from typing import List, Optional
from uuid import uuid4
@@ -242,53 +240,3 @@ async def notify_participants(room_name: str, notification_data: dict):
raise NotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
def generate_secure_token(length: int = 30, charset: str = ALPHANUMERIC_CHARSET) -> str:
"""Generate a cryptographically secure random token.
Uses SystemRandom for proper entropy, suitable for OAuth tokens
and API credentials that must be non-guessable.
Inspired by: https://github.com/oauthlib/oauthlib/blob/master/oauthlib/common.py
Args:
length: Token length in characters (default: 30)
charset: Character set to use for generation
Returns:
Cryptographically secure random token
"""
return "".join(secrets.choice(charset) for _ in range(length))
def generate_client_id() -> str:
"""Generate a unique client ID for application authentication.
Returns:
Random client ID string
"""
return generate_secure_token(settings.APPLICATION_CLIENT_ID_LENGTH)
def generate_client_secret() -> str:
"""Generate a secure client secret for application authentication.
Returns:
Cryptographically secure client secret
"""
return generate_secure_token(settings.APPLICATION_CLIENT_SECRET_LENGTH)
def generate_room_slug():
"""Generate a random room slug in the format 'xxx-xxxx-xxx'."""
sizes = [3, 4, 3]
parts = [
"".join(secrets.choice(string.ascii_lowercase) for _ in range(size))
for size in sizes
]
return "-".join(parts)
Binary file not shown.
+74 -167
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"POT-Creation-Date: 2025-07-11 11:33+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,153 +17,121 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:29
#: core/admin.py:26
msgid "Personal info"
msgstr "Persönliche Informationen"
#: core/admin.py:42
#: core/admin.py:39
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:54
#: core/admin.py:51
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:128 core/admin.py:228
#: core/admin.py:147
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:131 core/admin.py:231
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
#: core/admin.py:143
msgid "Resend notification to external service"
msgstr "Benachrichtigung erneut an externen Dienst senden"
#: core/admin.py:166
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
#: core/admin.py:174
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
#: core/admin.py:182
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
#: core/admin.py:190
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:294
msgid "No scopes"
msgstr "Keine Scopes"
#: core/admin.py:296
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
#: core/api/serializers.py:67
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/models.py:34
#: core/models.py:31
msgid "Member"
msgstr "Mitglied"
#: core/models.py:35
#: core/models.py:32
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:36
#: core/models.py:33
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:52
#: core/models.py:49
msgid "Initiated"
msgstr "Gestartet"
#: core/models.py:53
#: core/models.py:50
msgid "Active"
msgstr "Aktiv"
#: core/models.py:54
#: core/models.py:51
msgid "Stopped"
msgstr "Beendet"
#: core/models.py:55
#: core/models.py:52
msgid "Saved"
msgstr "Gespeichert"
#: core/models.py:56
#: core/models.py:53
msgid "Aborted"
msgstr "Abgebrochen"
#: core/models.py:57
#: core/models.py:54
msgid "Failed to Start"
msgstr "Start fehlgeschlagen"
#: core/models.py:58
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen"
#: core/models.py:59
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:86
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:87
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:93
#: core/models.py:90
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:94
#: core/models.py:91
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:95
#: core/models.py:92
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:107
#: core/models.py:104
msgid "id"
msgstr "ID"
#: core/models.py:108
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:114
#: core/models.py:111
msgid "created on"
msgstr "erstellt am"
#: core/models.py:115
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:120
#: core/models.py:117
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:121
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:141
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -171,11 +139,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:147
#: core/models.py:144
msgid "sub"
msgstr "Sub"
#: core/models.py:149
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -183,55 +151,55 @@ msgstr ""
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
"erlaubt."
#: core/models.py:157
#: core/models.py:154
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:162
#: core/models.py:159
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:164
#: core/models.py:161
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:166
#: core/models.py:163
msgid "short name"
msgstr "Kurzname"
#: core/models.py:172
#: core/models.py:169
msgid "language"
msgstr "Sprache"
#: core/models.py:173
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:179
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:182
#: core/models.py:179
msgid "device"
msgstr "Gerät"
#: core/models.py:184
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:187
#: core/models.py:184
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:189
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:192
#: core/models.py:189
msgid "active"
msgstr "aktiv"
#: core/models.py:195
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -239,66 +207,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:208
#: core/models.py:205
msgid "user"
msgstr "Benutzer"
#: core/models.py:209
#: core/models.py:206
msgid "users"
msgstr "Benutzer"
#: core/models.py:268
#: core/models.py:265
msgid "Resource"
msgstr "Ressource"
#: core/models.py:269
#: core/models.py:266
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:323
#: core/models.py:320
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:324
#: core/models.py:321
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:330
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:386
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:387
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:394
#: core/models.py:391
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:395
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:401 core/models.py:555
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Raum"
#: core/models.py:402
#: core/models.py:399
msgid "Rooms"
msgstr "Räume"
#: core/models.py:566
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:568
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -307,102 +275,42 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:576
#: core/models.py:573
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:577
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:583
#: core/models.py:580
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:584
#: core/models.py:581
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:692
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:693
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:699
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:705
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:711
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:728
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:729
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:731
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:732
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:745
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:767
msgid "Application"
msgstr "Anwendung"
#: core/models.py:768
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:794
msgid "Domain"
msgstr "Domain"
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:807
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:808
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -493,8 +401,7 @@ msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur "
"Organisatoren können sie herunterladen. "
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
@@ -531,18 +438,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:167
#: meet/settings.py:163
msgid "English"
msgstr "Englisch"
#: meet/settings.py:168
#: meet/settings.py:164
msgid "French"
msgstr "Französisch"
#: meet/settings.py:169
#: meet/settings.py:165
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:170
#: meet/settings.py:166
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+74 -170
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"POT-Creation-Date: 2025-07-11 11:33+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,151 +17,119 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:29
#: core/admin.py:26
msgid "Personal info"
msgstr "Personal info"
msgstr ""
#: core/admin.py:42
#: core/admin.py:39
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:54
#: core/admin.py:51
msgid "Important dates"
msgstr "Important dates"
#: core/admin.py:128 core/admin.py:228
#: core/admin.py:147
msgid "No owner"
msgstr "No owner"
#: core/admin.py:131 core/admin.py:231
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Multiple owners"
#: core/admin.py:143
msgid "Resend notification to external service"
msgstr "Resend notification to external service"
#: core/admin.py:166
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Failed to notify for recording %(id)s"
#: core/admin.py:174
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Failed to notify for recording %(id)s: %(error)s"
#: core/admin.py:182
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Successfully sent notifications for %(count)s recording(s)."
#: core/admin.py:190
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:294
msgid "No scopes"
msgstr "No scopes"
#: core/admin.py:296
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
#: core/api/serializers.py:67
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/models.py:34
#: core/models.py:31
msgid "Member"
msgstr "Member"
#: core/models.py:35
#: core/models.py:32
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:36
#: core/models.py:33
msgid "Owner"
msgstr "Owner"
#: core/models.py:52
#: core/models.py:49
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:53
#: core/models.py:50
msgid "Active"
msgstr "Active"
#: core/models.py:54
#: core/models.py:51
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:55
#: core/models.py:52
msgid "Saved"
msgstr "Saved"
#: core/models.py:56
#: core/models.py:53
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:57
#: core/models.py:54
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:58
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:59
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:86
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:87
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:93
#: core/models.py:90
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:94
#: core/models.py:91
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:95
#: core/models.py:92
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:107
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:108
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:114
#: core/models.py:111
msgid "created on"
msgstr "created on"
#: core/models.py:115
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:120
#: core/models.py:117
msgid "updated on"
msgstr "updated on"
#: core/models.py:121
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:141
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -169,11 +137,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:147
#: core/models.py:144
msgid "sub"
msgstr "sub"
#: core/models.py:149
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -181,55 +149,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:157
#: core/models.py:154
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:162
#: core/models.py:159
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:164
#: core/models.py:161
msgid "full name"
msgstr "full name"
#: core/models.py:166
#: core/models.py:163
msgid "short name"
msgstr "short name"
#: core/models.py:172
#: core/models.py:169
msgid "language"
msgstr "language"
#: core/models.py:173
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:179
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:182
#: core/models.py:179
msgid "device"
msgstr "device"
#: core/models.py:184
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:187
#: core/models.py:184
msgid "staff status"
msgstr "staff status"
#: core/models.py:189
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:192
#: core/models.py:189
msgid "active"
msgstr "active"
#: core/models.py:195
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -237,63 +205,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:208
#: core/models.py:205
msgid "user"
msgstr "user"
#: core/models.py:209
#: core/models.py:206
msgid "users"
msgstr "users"
#: core/models.py:268
#: core/models.py:265
msgid "Resource"
msgstr "Resource"
#: core/models.py:269
#: core/models.py:266
msgid "Resources"
msgstr "Resources"
#: core/models.py:323
#: core/models.py:320
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:324
#: core/models.py:321
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:330
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:386
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:387
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:394
#: core/models.py:391
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:395
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:401 core/models.py:555
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Room"
#: core/models.py:402
#: core/models.py:399
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:566
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:568
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -301,106 +269,42 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:576
#: core/models.py:573
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:577
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:583
#: core/models.py:580
msgid "Recording"
msgstr "Recording"
#: core/models.py:584
#: core/models.py:581
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:692
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:693
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:699
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:705
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:711
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:728
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:729
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:731
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:732
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:745
msgid "Application name"
msgstr "Application name"
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:767
msgid "Application"
msgstr "Application"
#: core/models.py:768
msgid "Applications"
msgstr "Applications"
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:794
msgid "Domain"
msgstr "Domain"
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:807
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:808
msgid "Application domains"
msgstr "Application domains"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -529,18 +433,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:167
#: meet/settings.py:163
msgid "English"
msgstr "English"
#: meet/settings.py:168
#: meet/settings.py:164
msgid "French"
msgstr "French"
#: meet/settings.py:169
#: meet/settings.py:165
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:170
#: meet/settings.py:166
msgid "German"
msgstr "German"
Binary file not shown.
+74 -167
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"POT-Creation-Date: 2025-07-11 11:33+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,155 +17,123 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:29
#: core/admin.py:26
msgid "Personal info"
msgstr "Informations personnelles"
#: core/admin.py:42
#: core/admin.py:39
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:54
#: core/admin.py:51
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:128 core/admin.py:228
#: core/admin.py:147
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:131 core/admin.py:231
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
#: core/admin.py:143
msgid "Resend notification to external service"
msgstr "Renvoyer la notification au service externe"
#: core/admin.py:166
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Échec de la notification pour lenregistrement %(id)s"
#: core/admin.py:174
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Échec de la notification pour lenregistrement %(id)s : %(error)s"
#: core/admin.py:182
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
#: core/admin.py:190
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
#: core/admin.py:294
msgid "No scopes"
msgstr "Aucun scopes"
#: core/admin.py:296
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
#: core/api/serializers.py:67
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/models.py:34
#: core/models.py:31
msgid "Member"
msgstr "Membre"
#: core/models.py:35
#: core/models.py:32
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:36
#: core/models.py:33
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:52
#: core/models.py:49
msgid "Initiated"
msgstr "Initié"
#: core/models.py:53
#: core/models.py:50
msgid "Active"
msgstr "Actif"
#: core/models.py:54
#: core/models.py:51
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:55
#: core/models.py:52
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:56
#: core/models.py:53
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:57
#: core/models.py:54
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:58
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:59
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:86
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:87
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:93
#: core/models.py:90
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:94
#: core/models.py:91
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:95
#: core/models.py:92
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:107
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:108
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:114
#: core/models.py:111
msgid "created on"
msgstr "créé le"
#: core/models.py:115
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:120
#: core/models.py:117
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:121
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:141
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -173,11 +141,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:147
#: core/models.py:144
msgid "sub"
msgstr "sub"
#: core/models.py:149
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -185,55 +153,55 @@ msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
#: core/models.py:157
#: core/models.py:154
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:162
#: core/models.py:159
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:164
#: core/models.py:161
msgid "full name"
msgstr "nom complet"
#: core/models.py:166
#: core/models.py:163
msgid "short name"
msgstr "nom court"
#: core/models.py:172
#: core/models.py:169
msgid "language"
msgstr "langue"
#: core/models.py:173
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:179
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:182
#: core/models.py:179
msgid "device"
msgstr "appareil"
#: core/models.py:184
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:187
#: core/models.py:184
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:189
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:192
#: core/models.py:189
msgid "active"
msgstr "actif"
#: core/models.py:195
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -241,65 +209,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:208
#: core/models.py:205
msgid "user"
msgstr "utilisateur"
#: core/models.py:209
#: core/models.py:206
msgid "users"
msgstr "utilisateurs"
#: core/models.py:268
#: core/models.py:265
msgid "Resource"
msgstr "Ressource"
#: core/models.py:269
#: core/models.py:266
msgid "Resources"
msgstr "Ressources"
#: core/models.py:323
#: core/models.py:320
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:324
#: core/models.py:321
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:330
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:386
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:387
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:394
#: core/models.py:391
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:395
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:401 core/models.py:555
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Salle"
#: core/models.py:402
#: core/models.py:399
msgid "Rooms"
msgstr "Salles"
#: core/models.py:566
#: core/models.py:563
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:568
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -307,102 +275,42 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:576
#: core/models.py:573
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:577
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:583
#: core/models.py:580
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:584
#: core/models.py:581
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:692
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:693
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:699
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:705
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:711
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:728
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:729
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:731
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:732
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:745
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun nouveau secret."
#: core/models.py:767
msgid "Application"
msgstr "Application"
#: core/models.py:768
msgid "Applications"
msgstr "Applications"
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:794
msgid "Domain"
msgstr "Domaine"
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:807
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:808
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -493,8 +401,7 @@ msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls "
"les organisateurs peuvent le télécharger."
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls les organisateurs peuvent le télécharger."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
@@ -531,18 +438,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:167
#: meet/settings.py:163
msgid "English"
msgstr "Anglais"
#: meet/settings.py:168
#: meet/settings.py:164
msgid "French"
msgstr "Français"
#: meet/settings.py:169
#: meet/settings.py:165
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:170
#: meet/settings.py:166
msgid "German"
msgstr "Allemand"
Binary file not shown.
+74 -167
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"POT-Creation-Date: 2025-07-11 11:33+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,152 +17,120 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:29
#: core/admin.py:26
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: core/admin.py:42
#: core/admin.py:39
msgid "Permissions"
msgstr "Rechten"
#: core/admin.py:54
#: core/admin.py:51
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/admin.py:128 core/admin.py:228
#: core/admin.py:147
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:131 core/admin.py:231
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
#: core/admin.py:143
msgid "Resend notification to external service"
msgstr "Melding opnieuw verzenden naar externe dienst"
#: core/admin.py:166
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Melding voor opname %(id)s mislukt"
#: core/admin.py:174
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
#: core/admin.py:182
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
#: core/admin.py:190
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s verlopen opname(n) overgeslagen."
#: core/admin.py:294
msgid "No scopes"
msgstr "Geen scopes"
#: core/admin.py:296
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
#: core/api/serializers.py:67
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/models.py:34
#: core/models.py:31
msgid "Member"
msgstr "Lid"
#: core/models.py:35
#: core/models.py:32
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:36
#: core/models.py:33
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:52
#: core/models.py:49
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:53
#: core/models.py:50
msgid "Active"
msgstr "Actief"
#: core/models.py:54
#: core/models.py:51
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:55
#: core/models.py:52
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:56
#: core/models.py:53
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:57
#: core/models.py:54
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:58
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:59
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:86
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:87
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:93
#: core/models.py:90
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:94
#: core/models.py:91
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:95
#: core/models.py:92
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:107
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:108
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:114
#: core/models.py:111
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:115
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:120
#: core/models.py:117
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:121
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:141
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -170,66 +138,66 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:147
#: core/models.py:144
msgid "sub"
msgstr "sub"
#: core/models.py:149
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
#: core/models.py:157
#: core/models.py:154
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:162
#: core/models.py:159
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:164
#: core/models.py:161
msgid "full name"
msgstr "volledige naam"
#: core/models.py:166
#: core/models.py:163
msgid "short name"
msgstr "korte naam"
#: core/models.py:172
#: core/models.py:169
msgid "language"
msgstr "taal"
#: core/models.py:173
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:179
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:182
#: core/models.py:179
msgid "device"
msgstr "apparaat"
#: core/models.py:184
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:187
#: core/models.py:184
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:189
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:192
#: core/models.py:189
msgid "active"
msgstr "actief"
#: core/models.py:195
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -237,64 +205,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:208
#: core/models.py:205
msgid "user"
msgstr "gebruiker"
#: core/models.py:209
#: core/models.py:206
msgid "users"
msgstr "gebruikers"
#: core/models.py:268
#: core/models.py:265
msgid "Resource"
msgstr "Bron"
#: core/models.py:269
#: core/models.py:266
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:323
#: core/models.py:320
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:324
#: core/models.py:321
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:330
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:386
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:387
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:394
#: core/models.py:391
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:395
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:401 core/models.py:555
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Ruimte"
#: core/models.py:402
#: core/models.py:399
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:566
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:568
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -302,102 +270,42 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:576
#: core/models.py:573
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:577
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:583
#: core/models.py:580
msgid "Recording"
msgstr "Opname"
#: core/models.py:584
#: core/models.py:581
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:692
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:693
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:699
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:705
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:711
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:728
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:729
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:731
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:732
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:745
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:767
msgid "Application"
msgstr "Applicatie"
#: core/models.py:768
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:794
msgid "Domain"
msgstr "Domein"
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:807
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:808
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -488,8 +396,7 @@ msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Het delen van de opname via een link is nog niet beschikbaar. Alleen "
"organisatoren kunnen deze downloaden."
"Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
@@ -526,18 +433,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:167
#: meet/settings.py:163
msgid "English"
msgstr "Engels"
#: meet/settings.py:168
#: meet/settings.py:164
msgid "French"
msgstr "Frans"
#: meet/settings.py:169
#: meet/settings.py:165
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:170
#: meet/settings.py:166
msgid "German"
msgstr "Duits"
-108
View File
@@ -10,8 +10,6 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
# pylint: disable=too-many-lines
import json
from os import path
from socket import gethostbyname, gethostname
@@ -71,10 +69,6 @@ class Base(Configuration):
USE_SWAGGER = False
API_VERSION = "v1.0"
EXTERNAL_API_VERSION = "v1.0"
EXTERNAL_API_ENABLED = values.BooleanValue(
False, environ_name="EXTERNAL_API_ENABLED", environ_prefix=None
)
DATA_DIR = values.Value(path.join("/", "data"), environ_name="DATA_DIR")
@@ -328,11 +322,6 @@ class Base(Configuration):
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGIN_ENABLED", environ_prefix=None
),
"idle_disconnect_warning_delay": values.PositiveIntegerValue(
None,
environ_name="FRONTEND_IDLE_DISCONNECT_WARNING_DELAY",
environ_prefix=None,
),
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
@@ -406,10 +395,6 @@ class Base(Configuration):
default=False,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
)
OIDC_TIMEOUT = values.IntegerValue(
5, environ_name="OIDC_TIMEOUT", environ_prefix=None
)
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
OIDC_RP_SIGN_ALGO = values.Value(
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
)
@@ -433,16 +418,12 @@ class Base(Configuration):
OIDC_OP_USER_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
)
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
)
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
)
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
)
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
)
@@ -503,45 +484,6 @@ class Base(Configuration):
environ_prefix=None,
)
# OIDC Resource Server Backend
OIDC_RS_BACKEND_CLASS = "core.external_api.authentication.ResourceServerBackend"
OIDC_RS_CLIENT_ID = values.Value(
"meet", environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
)
OIDC_RS_CLIENT_SECRET = SecretFileValue(
None,
environ_name="OIDC_RS_CLIENT_SECRET",
environ_prefix=None,
)
OIDC_RS_AUDIENCE_CLAIM = values.Value(
default="client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
)
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
default="A256GCM",
environ_name="OIDC_RS_ENCRYPTION_ENCODING",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_ALGO = values.Value(
default="RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None
)
OIDC_RS_SIGNING_ALGO = values.Value(
default="ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
)
OIDC_RS_SCOPES = values.ListValue(
default=["lasuite_meet"],
environ_name="OIDC_RS_SCOPES",
environ_prefix=None,
)
OIDC_RS_PRIVATE_KEY_STR = SecretFileValue(
environ_name="OIDC_RS_PRIVATE_KEY_STR", environ_prefix=None
)
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
default="RSA", environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE", environ_prefix=None
)
OIDC_RS_SCOPES_PREFIX = values.Value(
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
@@ -571,10 +513,6 @@ class Base(Configuration):
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
# Regex to filter webhook events by room name. Only matching events are processed.
LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = values.Value(
None, environ_name="LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX", environ_prefix=None
)
RESOURCE_DEFAULT_ACCESS_LEVEL = values.Value(
"public", environ_name="RESOURCE_DEFAULT_ACCESS_LEVEL", environ_prefix=None
)
@@ -726,51 +664,6 @@ class Base(Configuration):
environ_prefix=None,
)
# External Applications
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
40,
environ_name="APPLICATION_CLIENT_ID_LENGTH",
environ_prefix=None,
)
APPLICATION_CLIENT_SECRET_LENGTH = values.PositiveIntegerValue(
128,
environ_name="APPLICATION_CLIENT_SECRET_LENGTH",
environ_prefix=None,
)
APPLICATION_JWT_SECRET_KEY = SecretFileValue(
None, environ_name="APPLICATION_JWT_SECRET_KEY", environ_prefix=None
)
APPLICATION_JWT_ALG = values.Value(
"HS256",
environ_name="APPLICATION_JWT_ALG",
environ_prefix=None,
)
APPLICATION_JWT_ISSUER = values.Value(
"lasuite-meet",
environ_name="APPLICATION_JWT_ISSUER",
environ_prefix=None,
)
APPLICATION_JWT_AUDIENCE = values.Value(
None,
environ_name="APPLICATION_JWT_AUDIENCE",
environ_prefix=None,
)
APPLICATION_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue(
3600,
environ_name="APPLICATION_JWT_EXPIRATION_SECONDS",
environ_prefix=None,
)
APPLICATION_JWT_TOKEN_TYPE = values.Value(
"Bearer",
environ_name="APPLICATION_JWT_TOKEN_TYPE",
environ_prefix=None,
)
APPLICATION_BASE_URL = values.Value(
None,
environ_name="APPLICATION_BASE_URL",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -889,7 +782,6 @@ class Test(Base):
"django.contrib.auth.hashers.MD5PasswordHasher",
]
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
-1
View File
@@ -15,7 +15,6 @@ from drf_spectacular.views import (
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("core.urls")),
path("", include("lasuite.oidc_resource_server.urls")),
]
if settings.DEBUG:
+31 -31
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.0.1"
version = "0.1.38"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,38 +25,38 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.40.69",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"boto3==1.38.42",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.0.0",
"django-lasuite[all]==0.0.19",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-lasuite[all]==0.0.10",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.9",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"django==5.2.6",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10.1",
"easy_thumbnails==2.10",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"markdown==3.10",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.2.12",
"jsonschema==4.24.0",
"markdown==3.8.2",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.9",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
"requests==2.32.5",
"sentry-sdk==2.43.0",
"whitenoise==6.11.0",
"requests==2.32.4",
"sentry-sdk==2.30.0",
"whitenoise==6.9.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7",
"aiohttp==3.13.2",
"livekit-api==1.0.3",
"aiohttp==3.12.13",
]
[project.urls]
@@ -68,21 +68,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"drf-spectacular-sidecar==2025.6.1",
"freezegun==1.5.2",
"ipdb==0.13.13",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"ipython==9.3.0",
"pyfakefs==5.9.1",
"pylint-django==2.6.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pylint==3.3.7",
"pytest-cov==6.2.1",
"pytest-django==4.11.1",
"pytest==9.0.0",
"pytest==8.4.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.4",
"types-requests==2.32.4.20250913",
"pytest-xdist==3.7.0",
"responses==0.25.7",
"ruff==0.12.0",
"types-requests==2.32.4.20250611",
]
[tool.setuptools]
+1 -6
View File
@@ -38,12 +38,7 @@ RUN npm run build
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 \
libcrypto3 \
libxml2>=2.12.7-r2 \
libxslt>=1.1.39-r2 \
libexpat>=2.7.2-r0 \
libpng>=1.6.53-r0
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
USER nginx
+30 -39
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.0.1",
"version": "0.1.38",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.0.1",
"version": "0.1.38",
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
@@ -55,7 +55,7 @@
"postcss": "8.5.6",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "7.0.8",
"vite": "7.0.2",
"vite-tsconfig-paths": "5.1.4"
}
},
@@ -104,7 +104,6 @@
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -3819,6 +3818,17 @@
"win32"
]
},
"node_modules/@rrweb/types": {
"version": "2.0.0-alpha.17",
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.17.tgz",
"integrity": "sha512-AfDTVUuCyCaIG0lTSqYtrZqJX39ZEYzs4fYKnexhQ+id+kbZIpIJtaut5cto6dWZbB3SEe4fW0o90Po3LvTmfg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"rrweb-snapshot": "^2.0.0-alpha.17"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz",
@@ -3870,7 +3880,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz",
"integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.81.5"
},
@@ -3962,7 +3971,8 @@
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/dom-mediacapture-transform": {
"version": "0.1.11",
@@ -3978,7 +3988,8 @@
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.15.tgz",
"integrity": "sha512-omOlCPvTWyPm4ZE5bZUhlSvnHM2ZWM2U+1cPiYFL/e8aV5O9MouELp+L4dMKNTON0nTeHqEg+KWDfFQMY5Wkaw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -4005,7 +4016,6 @@
"integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4023,7 +4033,6 @@
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -4090,7 +4099,6 @@
"integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/types": "8.35.1",
@@ -4382,7 +4390,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4797,7 +4804,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.5.4",
@@ -5607,7 +5613,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -6575,7 +6580,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -7460,7 +7464,6 @@
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.7.tgz",
"integrity": "sha512-19m8Q1cvRl5PslRawDUgWXeP8vL8584tX8kiZEJaPZo83U/L6VPS/O7pP06phfJaBWeeV8sAOVtEPlQiZEHtpg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.39.3",
@@ -8120,7 +8123,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8234,7 +8236,6 @@
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -8388,7 +8389,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -8495,7 +8495,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -8745,6 +8744,7 @@
"integrity": "sha512-GBg5pV8LHOTbeVmH2VHLEFR0mc2QpQMzAvcoxEGfPNWgWHc8UvKCyq7pqN1vA+fDZ+yXXbixeO0kB2pzVvFCBw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"postcss": "^8.4.38"
}
@@ -9248,7 +9248,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -9367,8 +9366,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -9480,7 +9478,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9641,7 +9638,6 @@
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.5.tgz",
"integrity": "sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==",
"license": "MIT",
"peer": true,
"dependencies": {
"proxy-compare": "^3.0.1"
},
@@ -9737,16 +9733,15 @@
}
},
"node_modules/vite": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.8.tgz",
"integrity": "sha512-cJBdq0/u+8rgstg9t7UkBilf8ipLmeXJO30NxD5HAHOivnj10ocV8YtR/XBvd2wQpN3TmcaxNKaHX3tN7o5F5A==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.2.tgz",
"integrity": "sha512-hxdyZDY1CM6SNpKI4w4lcUc3Mtkd9ej4ECWVHSMrOdSinVc2zYOAppHeGc/hzmRo3pxM5blMzkuWHOJA/3NiFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
"picomatch": "^4.0.3",
"picomatch": "^4.0.2",
"postcss": "^8.5.6",
"rollup": "^4.40.0",
"tinyglobby": "^0.2.14"
@@ -9854,14 +9849,11 @@
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -9872,12 +9864,11 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.0.1",
"version": "0.1.38",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -60,7 +60,7 @@
"postcss": "8.5.6",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "7.0.8",
"vite": "7.0.2",
"vite-tsconfig-paths": "5.1.4"
}
}
-1
View File
@@ -25,7 +25,6 @@ export interface ApiConfig {
custom_css_url?: string
use_french_gov_footer?: boolean
use_proconnect_button?: boolean
idle_disconnect_warning_delay?: number
recording?: {
is_enabled?: boolean
available_modes?: RecordingMode[]
-1
View File
@@ -68,7 +68,6 @@ export const Avatar = ({
{...props}
>
<span
aria-hidden="true"
className={css({
marginTop: '-0.3rem',
})}
@@ -5,6 +5,6 @@ export const authUrl = ({
returnTo = window.location.href,
} = {}) => {
return apiUrl(
`/authenticate/?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`
`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`
)
}
@@ -71,11 +71,7 @@ export const LaterMeetingDialog = ({
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
) : (
<RiFileCopyLine aria-hidden="true" />
)}
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
</Button>
)}
</div>
@@ -107,18 +103,13 @@ export const LaterMeetingDialog = ({
>
{isCopied ? (
<>
<RiCheckLine
size={18}
style={{ marginRight: '8px' }}
aria-hidden="true"
/>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
aria-hidden="true"
/>
{t('copy')}
</>
@@ -140,11 +131,7 @@ export const LaterMeetingDialog = ({
>
{isCopied ? (
<>
<RiCheckLine
size={18}
style={{ marginRight: '8px' }}
aria-hidden="true"
/>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
@@ -152,7 +139,6 @@ export const LaterMeetingDialog = ({
<RiFileCopyLine
size={18}
style={{ marginRight: '8px', minWidth: '18px' }}
aria-hidden="true"
/>
{isHovered ? (
t('copy')
@@ -187,7 +173,6 @@ export const LaterMeetingDialog = ({
className={css({
fill: 'primary.500',
})}
aria-hidden="true"
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { useRoomContext } from '@livekit/components-react'
import { Spinner } from '@/primitives/Spinner'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Text } from '@/primitives'
import { RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
@@ -31,9 +31,6 @@ export const RecordingStateToast = () => {
const { openTranscript, openScreenRecording } = useSidePanel()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const recordingSnap = useSnapshot(recordingStore)
const hasTranscriptAccess = useHasRecordingAccess(
@@ -131,23 +128,6 @@ export const RecordingStateToast = () => {
}
}, [recordingSnap])
// Update screen reader message only when the key actually changes
// This prevents duplicate announcements caused by re-renders
useEffect(() => {
if (key && key !== lastKeyRef.current) {
lastKeyRef.current = key
const message = t(key)
setSrMessage(message)
// Clear message after 3 seconds to prevent it from being announced again
const timer = setTimeout(() => {
setSrMessage('')
}, 3000)
return () => clearTimeout(timer)
}
}, [key, t])
if (!key)
return isAdminOrOwner ? (
<LimitReachedAlertDialog
@@ -164,81 +144,67 @@ export const RecordingStateToast = () => {
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
return (
<>
{/* Screen reader only message to announce state changes once */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{srMessage}
</div>
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
{isStarted ? (
<RiRecordCircleLine
size={20}
className={css({
animation: 'pulse_background 1s infinite',
})}
/>
) : (
<Spinner size={20} variant="dark" />
)}
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
{isStarted ? (
<RiRecordCircleLine
size={20}
className={css({
animation: 'pulse_background 1s infinite',
})}
/>
) : (
<Spinner size={20} variant="dark" />
)}
{!hasScreenRecordingAccessAndActive &&
!hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
</>
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
)
}
@@ -9,7 +9,7 @@ import {
useStartRecording,
useStopRecording,
} from '@/features/recording'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
@@ -64,17 +64,6 @@ export const ScreenRecordingSidePanel = () => {
const isRoomConnected = room.state == ConnectionState.Connected
const isRecordingTransitioning = useIsRecordingTransitioning()
// Focus management: focus the primary action button when this side panel opens.
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
requestAnimationFrame(() => {
if (primaryActionRef.current) {
primaryActionRef.current.focus({ preventScroll: true })
}
})
}, [])
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
@@ -164,7 +153,6 @@ export const ScreenRecordingSidePanel = () => {
{t('stop.body')}
</Text>
<Button
ref={primaryActionRef}
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="stop-screen-recording"
@@ -231,7 +219,6 @@ export const ScreenRecordingSidePanel = () => {
)}
</Text>
<Button
ref={primaryActionRef}
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="start-screen-recording"
@@ -11,7 +11,7 @@ import {
useStopRecording,
useHasFeatureWithoutAdminRights,
} from '../index'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
@@ -77,17 +77,6 @@ export const TranscriptSidePanel = () => {
const room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected
// Focus management: focus the primary action button when this side panel opens.
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
if (primaryActionRef.current) {
requestAnimationFrame(() => {
primaryActionRef.current?.focus({ preventScroll: true })
})
}
}, [])
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
@@ -238,7 +227,6 @@ export const TranscriptSidePanel = () => {
{t('stop.body')}
</Text>
<Button
ref={primaryActionRef}
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="stop-transcript"
@@ -308,7 +296,6 @@ export const TranscriptSidePanel = () => {
)}
</Text>
<Button
ref={primaryActionRef}
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="start-transcript"
@@ -30,7 +30,6 @@ import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
export const Conference = ({
roomId,
@@ -170,8 +169,6 @@ export const Conference = ({
kind: null,
})
const isMobile = useIsMobile()
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
@@ -241,7 +238,7 @@ export const Conference = ({
}}
>
<VideoConference />
{showInviteDialog && !isMobile && (
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
@@ -67,7 +67,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
gap={0}
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
{t('heading')}
</Heading>
<Div position="absolute" top="5" right="5">
@@ -115,11 +115,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
) : (
<RiFileCopyLine aria-hidden="true" />
)}
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
</Button>
)}
</div>
@@ -151,18 +147,13 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
>
{isCopied ? (
<>
<RiCheckLine
size={18}
style={{ marginRight: '8px' }}
aria-hidden="true"
/>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
aria-hidden="true"
/>
{t('copy')}
</>
@@ -71,6 +71,7 @@ export const FullScreenShareWarning = ({
<div
className={css({
position: 'absolute',
zIndex: '1000',
height: '100%',
width: '100%',
})}
@@ -80,6 +81,7 @@ export const FullScreenShareWarning = ({
<div
className={css({
position: 'absolute',
zIndex: '1000',
height: '100%',
width: '100%',
backgroundColor: 'rgba(22, 22, 34, 0.9)',
@@ -113,7 +115,6 @@ export const FullScreenShareWarning = ({
display: 'flex',
flexDirection: 'row',
gap: '1rem',
zIndex: '1000',
})}
>
<Button
@@ -78,20 +78,12 @@ export const Info = () => {
>
{isCopied ? (
<>
<RiCheckLine
size={24}
style={{ marginRight: '6px' }}
aria-hidden="true"
/>
<RiCheckLine size={24} style={{ marginRight: '6px' }} />
{t('roomInformation.button.copied')}
</>
) : (
<>
<RiFileCopyLine
size={24}
style={{ marginRight: '6px' }}
aria-hidden="true"
/>
<RiFileCopyLine size={24} style={{ marginRight: '6px' }} />
{t('roomInformation.button.copy')}
</>
)}
@@ -1,102 +0,0 @@
import { Button, Dialog, H, P } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
export const IsIdleDisconnectModal = () => {
const connectionObserverSnap = useSnapshot(connectionObserverStore)
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
useEffect(() => {
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
setTimeRemaining(IDLE_DISCONNECT_TIMEOUT_MS)
const interval = setInterval(() => {
setTimeRemaining((prev) => {
if (prev <= 1000) {
clearInterval(interval)
connectionObserverStore.isIdleDisconnectModalOpen = false
navigateTo('feedback', { duplicateIdentity: false })
return 0
}
return prev - 1000
})
}, 1000)
return () => clearInterval(interval)
}
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
const minutes = Math.floor(timeRemaining / 1000 / 60)
const seconds = (timeRemaining / 1000) % 60
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
return (
<Dialog
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
role="alertdialog"
type="alert"
aria-label={t('title')}
onClose={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
}}
>
{({ close }) => {
return (
<div>
<div
className={css({
height: '50px',
width: '50px',
backgroundColor: 'blue.100',
borderRadius: '25px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontWeight: '500',
color: 'blue.800',
margin: 'auto',
})}
>
{formattedTime}
</div>
<H lvl={2} centered>
{t('title')}
</H>
<P>
{t('body', {
duration: humanizeDuration(IDLE_DISCONNECT_TIMEOUT_MS, {
language: i18n.language,
}),
})}
</P>
<P>{t('settings')}</P>
<HStack marginTop="2rem">
<Button
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
navigateTo('feedback', { duplicateIdentity: false })
}}
size="sm"
variant="secondary"
>
{t('leaveButton')}
</Button>
<Button onPress={close} size="sm" variant="primary">
{t('stayButton')}
</Button>
</HStack>
</div>
)
}}
</Dialog>
)
}
@@ -1,32 +0,0 @@
import React, { ReactNode } from 'react'
import { css } from '@/styled-system/css'
export interface KeyboardShortcutHintProps {
children: ReactNode
}
/**
* Small reusable bubble used to display and announce keyboard shortcuts,
* typically when an element receives keyboard focus.
*/
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
children,
}) => {
return (
<div
className={css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
paddingInline: '0.5rem',
paddingBlock: '0.1rem',
fontSize: '0.875rem',
})}
>
{children}
</div>
)
}
@@ -35,7 +35,6 @@ export const ParticipantName = ({
style={{
paddingBottom: '0.1rem',
}}
aria-hidden="true"
>
{displayedName}
</Text>
@@ -29,9 +29,6 @@ import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next'
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -105,31 +102,9 @@ export const ParticipantTile: (
})
const isScreenShare = trackReference.source != Track.Source.Camera
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantName = getParticipantName(trackReference.participant)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = {
...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onFocus?.(event)
setHasKeyboardFocus(true)
},
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onBlur?.(event)
const nextTarget = event.relatedTarget as Node | null
if (!event.currentTarget.contains(nextTarget)) {
setHasKeyboardFocus(false)
}
},
}
return (
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
<FullScreenShareWarning trackReference={trackReference} />
@@ -220,16 +195,10 @@ export const ParticipantTile: (
</>
)}
{!disableMetadata && (
<ParticipantTileFocus
trackRef={trackReference}
hasKeyboardFocus={hasKeyboardFocus}
/>
<ParticipantTileFocus trackRef={trackReference} />
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
{hasKeyboardFocus && (
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
)}
</div>
)
})
@@ -131,10 +131,8 @@ const MOUSE_IDLE_TIME = 3000
export const ParticipantTileFocus = ({
trackRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
hasKeyboardFocus: boolean
}) => {
const [hovered, setHovered] = useState(false)
const [opacity, setOpacity] = useState(0)
@@ -142,10 +140,8 @@ export const ParticipantTileFocus = ({
const idleTimerRef = useRef<number | null>(null)
const [isIdleRef, setIsIdleRef] = useState(false)
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
useEffect(() => {
if (isVisible) {
if (hovered && !isIdleRef) {
// Wait for next frame to ensure element is mounted
requestAnimationFrame(() => {
setOpacity(0.6)
@@ -153,7 +149,7 @@ export const ParticipantTileFocus = ({
} else {
setOpacity(0)
}
}, [isVisible])
}, [hovered, isIdleRef])
const handleMouseMove = () => {
if (idleTimerRef.current) {
@@ -184,12 +180,11 @@ export const ParticipantTileFocus = ({
width: '100%',
height: '100%',
})}
aria-hidden={!isVisible}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove}
>
{isVisible && (
{hovered && (
<div
className={css({
backgroundColor: 'primaryDark.50',
@@ -164,7 +164,7 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen} keepAlive={true}>
<Chat />
</Panel>
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Panel isOpen={isToolsOpen}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>

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