Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2861aa143e | |||
| 4748b55089 | |||
| ce9f812a7e | |||
| b02591170f | |||
| e58181f846 | |||
| d37f47e82c | |||
| db80c09c10 | |||
| fd9f2a81ca | |||
| d865db5f1b | |||
| 7cc5b2b961 | |||
| c85977cb68 | |||
| 3c3b4a32e3 | |||
| 9b033c55b2 | |||
| a2c7becaf4 | |||
| 89031abb63 | |||
| fc92fa4eb4 | |||
| 2c65cc061e | |||
| bfadeae6ee | |||
| 117677bd14 | |||
| 69c6e58017 | |||
| 6742f5d19d | |||
| 23de7e52bc | |||
| 3887255e9c | |||
| 5d6ad3f3f6 | |||
| 44d68a9c80 | |||
| ed5c1bbd84 | |||
| f8c6da8021 | |||
| 5ba1657e00 | |||
| c28b8ba902 | |||
| 6962367e18 | |||
| 0bd57e8623 | |||
| 27f2023104 | |||
| 44362eca23 | |||
| c34a85699b | |||
| 12d8c4a9db | |||
| 42a05da5c0 | |||
| 4344dd6e35 | |||
| fe28902b2e | |||
| 1e1e1a2657 | |||
| f4e48dafac | |||
| 9f58efb851 | |||
| 716e11b5b3 | |||
| 88a1136dfd | |||
| 90633928a8 | |||
| fd894eb61f | |||
| bb64532cff | |||
| 692c55ed1b | |||
| df616ae711 | |||
| 021d7a7e06 | |||
| f2a3e7c8de | |||
| cf07ceb67e | |||
| ea7fb5fc27 | |||
| 6e8a6ce82a | |||
| ce960ae330 | |||
| f9dd2e1909 | |||
| 9023e54352 | |||
| 8295574616 | |||
| db15c8b6cc | |||
| e1aeec6053 | |||
| c5aa762e11 | |||
| 8f710a4626 | |||
| 60d1338eff | |||
| f8436d9ae2 | |||
| 39fb273201 | |||
| d101459115 | |||
| 88696a23fd | |||
| 13d26a76b3 | |||
| b675517a60 | |||
| a5254ffd59 | |||
| ff82bca9ec | |||
| 99a18b6e90 | |||
| 250e599465 | |||
| 144a4e1b85 | |||
| 78ab3cdbdf | |||
| a815d6c00d | |||
| dfbc3a9d17 | |||
| 086db3d089 | |||
| 014ef3d804 | |||
| de3e1a56a8 | |||
| 459749b992 | |||
| e1450329f2 | |||
| c7e3194331 | |||
| 902b005f32 | |||
| 51d22783b2 | |||
| 76f80a0f2f | |||
| 82eb930200 | |||
| eeeb950e08 | |||
| cb77688572 | |||
| f9524b2f0a | |||
| a50aabeaf8 | |||
| 594bd5a692 |
+75
-1
@@ -8,6 +8,80 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) Use React Aria FocusScope for side panel accessibility
|
||||
|
||||
## [1.7.0] - 2026-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) expose Windows app web link #976
|
||||
- ✨(frontend) support additional shortcuts to broaden accessibility
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(frontend) add clickable settings general link in idle modal #974
|
||||
|
||||
## [1.6.0] - 2026-02-10
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) monitor throttling rate failure through sentry #964
|
||||
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) improve spinner reduced‑motion fallback #931
|
||||
- ♿️(frontend) fix form labels and autocomplete wiring #932
|
||||
- 🥅(summary) catch file-related exceptions when handling recording #944
|
||||
- 📝(frontend) update legal terms #956
|
||||
- ⚡️(backend) enhance django admin's loading performance #954
|
||||
- 🌐(frontend) add missing DE translation for accessibility settings
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔐(backend) enforce object-level permission checks on room endpoint #959
|
||||
- 🔒️(backend) add application validation when consuming external JWT #963
|
||||
|
||||
## [1.5.0] - 2026-01-28
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
|
||||
- ♿️(frontend) sr pin/unpin announcements with dedicated messages #898
|
||||
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
|
||||
- ♿️(frontend) add global screen reader announcer#922
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(frontend) fix an XSS vulnerability on the recording page #911
|
||||
|
||||
## [1.4.0] - 2026-01-25
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add configurable redirect for unauthenticated users #904
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) add accessible back button in side panel #881
|
||||
- ♿️(frontend) improve participants toggle a11y label #880
|
||||
- ♿️(frontend) make carousel image decorative #871
|
||||
- ♿️(frontend) reactions are now vocalized and configurable #849
|
||||
- ♿️(frontend) improve background effect announcements #879
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒(backend) prevent automatic upgrade setuptools
|
||||
- ♿(frontend) improve contrast for selected options #863
|
||||
- ♿️(frontend) announce copy state in invite dialog #877
|
||||
- 📝(frontend) align close dialog label in rooms locale #878
|
||||
- 🩹(backend) use case-insensitive email matching in the external api #887
|
||||
- 🐛(frontend) ensure transcript segments are sorted by their timestamp #899
|
||||
- 🐛(frontend) scope scrollbar gutter override to video rooms #882
|
||||
|
||||
## [1.3.0] - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -25,7 +99,7 @@ and this project adheres to
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
|
||||
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
|
||||
- 🩹(frontend) icon font loading to avoid text/icon flickering
|
||||
|
||||
## [1.2.0] - 2026-01-05
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
FROM python:3.13.5-alpine3.21 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
web: bin/buildpack_start.sh
|
||||
postdeploy: python manage.py migrate
|
||||
@@ -50,6 +50,9 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
|
||||
|
||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||
|
||||
### 🚀 Major roll out to all French public servants
|
||||
|
||||
On the 25th of January 2026, David Amiel, France’s Minister for Civil Service and State Reform, announced the full deployment of Visio—the French government’s dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -86,7 +89,7 @@ We hope to see many more, here is an incomplete list of public La Suite Meet ins
|
||||
| [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. |
|
||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit # always exit on error
|
||||
set -o pipefail # don't ignore exit codes when piping output
|
||||
|
||||
echo "-----> Running post-compile script"
|
||||
|
||||
# Cleanup
|
||||
rm -rf docker docs env.d gitlint
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit # always exit on error
|
||||
set -o pipefail # don't ignore exit codes when piping output
|
||||
|
||||
echo "-----> Running post-frontend script"
|
||||
|
||||
# Move the frontend build to the nginx root and clean up
|
||||
mkdir -p build/
|
||||
mv src/frontend/dist build/frontend-out
|
||||
|
||||
ASSETS_DIR=build/frontend-out/assets
|
||||
if [ -n "$CUSTOM_LOGO_URL" ]; then
|
||||
# Ensure https
|
||||
[[ ! "$CUSTOM_LOGO_URL" =~ ^https:// ]] && echo "[custom-logo] ERROR: URL must use HTTPS" >&2 && exit 1
|
||||
|
||||
# Prevent SSRF
|
||||
HOSTNAME=$(echo "$CUSTOM_LOGO_URL" | sed -E 's|^https://([^/:]+).*|\1|')
|
||||
[[ "$HOSTNAME" =~ ^(localhost|127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|0\.0\.0\.0|\[::1\]) ]] && echo "[custom-logo] ERROR: SSRF blocked: $HOSTNAME" >&2 && exit 1
|
||||
|
||||
LOGO_FILE="${ASSETS_DIR}/logo.svg"
|
||||
TMP_FILE=$(mktemp "${LOGO_FILE}.XXXXXX.tmp")
|
||||
|
||||
# Actual download
|
||||
echo "[custom-logo] INFO: Downloading custom logo from: $CUSTOM_LOGO_URL"
|
||||
curl -fsSL --tlsv1.2 -o "$TMP_FILE" "$CUSTOM_LOGO_URL"
|
||||
|
||||
# Validate filesize
|
||||
FILESIZE=$(stat -c%s "$TMP_FILE" 2>/dev/null || stat -f%z "$TMP_FILE")
|
||||
[[ "$FILESIZE" -eq 0 ]] && echo "[custom-logo] ERROR: empty file" >&2 && exit 1
|
||||
[[ "$FILESIZE" -gt 5242880 ]] && echo "[custom-logo] ERROR: file too large (${FILESIZE}B > 5MB)" >&2 && exit 1
|
||||
|
||||
# Validate file type
|
||||
IS_SVG=false
|
||||
|
||||
HEADER=$(head -c 100 "$TMP_FILE" | tr -d '\0' | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$HEADER" =~ ^.*"<svg".*$ ]] && IS_SVG=true
|
||||
[[ "$HEADER" =~ ^.*"<?xml".*"<svg".*$ ]] && IS_SVG=true
|
||||
|
||||
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
|
||||
|
||||
mv -f "$TMP_FILE" "$LOGO_FILE"
|
||||
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
|
||||
fi
|
||||
|
||||
mv src/backend/* ./
|
||||
mv deploy/paas/* ./
|
||||
|
||||
echo "3.13" > .python-version
|
||||
echo "." > requirements.txt
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Start the Django backend server
|
||||
gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
|
||||
|
||||
# Start the Nginx server
|
||||
bin/run &
|
||||
|
||||
# if the current shell is killed, also terminate all its children
|
||||
trap "pkill SIGTERM -P $$" SIGTERM
|
||||
|
||||
# wait for a single child to finish,
|
||||
wait -n
|
||||
# then kill all the other tasks
|
||||
pkill -P $$
|
||||
@@ -0,0 +1,52 @@
|
||||
# ERB templated nginx configuration
|
||||
# see https://doc.scalingo.com/platform/deployment/buildpacks/nginx
|
||||
|
||||
upstream backend_server {
|
||||
server localhost:8000 fail_timeout=0;
|
||||
}
|
||||
|
||||
server {
|
||||
listen <%= ENV["PORT"] %>;
|
||||
server_name _;
|
||||
server_tokens off;
|
||||
|
||||
root /app/build/frontend-out;
|
||||
|
||||
# Django rest framework
|
||||
location ^~ /api/ {
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_redirect off;
|
||||
proxy_pass http://backend_server;
|
||||
}
|
||||
|
||||
# Django admin
|
||||
location ^~ /admin/ {
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_redirect off;
|
||||
proxy_pass http://backend_server;
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# Add no-cache headers
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
|
||||
add_header Expires 0;
|
||||
}
|
||||
|
||||
# Optionally, handle 404 errors by redirecting to index.html
|
||||
error_page 404 =200 /index.html;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
upstream meet_backend {
|
||||
server ${BACKEND_HOST}:8000 fail_timeout=0;
|
||||
server ${BACKEND_INTERNAL_HOST}:8000 fail_timeout=0;
|
||||
}
|
||||
|
||||
upstream meet_frontend {
|
||||
server ${FRONTEND_HOST}:8080 fail_timeout=0;
|
||||
server ${FRONTEND_INTERNAL_HOST}:8080 fail_timeout=0;
|
||||
}
|
||||
|
||||
server {
|
||||
|
||||
@@ -3,3 +3,8 @@ redis:
|
||||
address: redis:6379
|
||||
keys:
|
||||
devkey: secret
|
||||
|
||||
webhook:
|
||||
api_key: devkey
|
||||
urls:
|
||||
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
- env.d/postgresql
|
||||
- env.d/common
|
||||
volumes:
|
||||
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
||||
- ./data/databases/backend:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
@@ -20,8 +20,8 @@ services:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
- env.d/common
|
||||
- env.d/backend
|
||||
- env.d/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "manage.py", "check"]
|
||||
@@ -45,6 +45,7 @@ services:
|
||||
- /docker-entrypoint.sh
|
||||
command: ["nginx", "-g", "daemon off;"]
|
||||
env_file:
|
||||
- .env
|
||||
- env.d/common
|
||||
# Uncomment and set your values if using our nginx proxy example
|
||||
# environment:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
### Step 1: Prepare your working environment:
|
||||
|
||||
```bash
|
||||
mkdir keycloak/env.d && cd keycloak
|
||||
mkdir -p 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
|
||||
@@ -89,4 +89,4 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||
#### Step 3: Get Client Credentials
|
||||
|
||||
1. Go to the "Credentials" tab.
|
||||
2. Copy the client ID (`meet` in this example) and the client secret.
|
||||
2. Copy the client ID (`meet` in this example) and the client secret.
|
||||
|
||||
@@ -9,6 +9,10 @@ La Suite Meet maintainers use only the Kubernetes deployment method in productio
|
||||
We understand that not everyone has a Kubernetes cluster available, please follow the instructions provided [here](/docs/installation/compose.md) to set up a docker compose instance.
|
||||
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
|
||||
|
||||
## Scalingo
|
||||
|
||||
La Suite Meet can be deployed on Scalingo PaaS using the Suite Numérique buildpack. See the [Scalingo deployment guide](./scalingo.md) for detailed instructions.
|
||||
|
||||
## Other ways to install La Suite Meet
|
||||
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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)
|
||||
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/kubernetes.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -47,7 +47,7 @@ curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/m
|
||||
|
||||
## Step 2: Configuration
|
||||
|
||||
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
|
||||
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../../src/helm/meet/README.md).
|
||||
|
||||
In this example, we assume the following services:
|
||||
|
||||
@@ -129,7 +129,7 @@ The following ports will need to be opened:
|
||||
- 7881/tcp - WebRTC ICE over TCP
|
||||
- 7882/udp - for WebRTC multiplexing over UDP
|
||||
|
||||
If you are using ufw, enter the follwoing:
|
||||
If you are using ufw, enter the following:
|
||||
```
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
@@ -177,6 +177,15 @@ You will need to uncomment the environment and network sections in compose file
|
||||
# external: true
|
||||
```
|
||||
|
||||
#### Caddy Reverse Proxy
|
||||
Expose the Frontend port to the host
|
||||
```yaml
|
||||
frontend:
|
||||
…
|
||||
ports:
|
||||
- "8086:8086"
|
||||
```
|
||||
|
||||
## Step 5: Start Meet
|
||||
|
||||
You are ready to start your Meet application !
|
||||
@@ -198,7 +207,7 @@ Replace `<admin email>` with the email of your admin user and generate a secure
|
||||
|
||||
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.
|
||||
The admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
|
||||
|
||||
## How to upgrade your Meet application
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Deployment on Scalingo
|
||||
|
||||
This guide explains how to deploy La Suite Meet on [Scalingo](https://scalingo.com/) using the [Suite Numérique buildpack](https://github.com/suitenumerique/buildpack).
|
||||
|
||||
## Overview
|
||||
|
||||
Scalingo is a Platform-as-a-Service (PaaS) that simplifies application deployment. This setup uses a custom buildpack to handle both the frontend (Vite) and backend (Django) builds, serving them through Nginx.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Scalingo account
|
||||
- Scalingo CLI installed (optional but recommended)
|
||||
- A PostgreSQL database addon
|
||||
- A Redis addon (for caching and sessions)
|
||||
|
||||
## Step 1: Create Your App
|
||||
|
||||
Create a new app on Scalingo using `scalingo` cli or using the [Scalingo dashboard](https://dashboard.scalingo.com/).
|
||||
|
||||
## Step 2: Provision Addons
|
||||
|
||||
Add the required PostgreSQL and Redis services.
|
||||
|
||||
This will set the following environment variables automatically:
|
||||
- `SCALINGO_POSTGRESQL_URL` - Database connection string
|
||||
- `SCALINGO_REDIS_URL` - Redis connection string
|
||||
|
||||
## Step 3: Configure Environment Variables
|
||||
|
||||
Set the following environment variables in your Scalingo app:
|
||||
|
||||
### Buildpack Configuration
|
||||
|
||||
```bash
|
||||
scalingo env-set BUILDBACK_URL="https://github.com/suitenumerique/buildpack#main"
|
||||
scalingo env-set LASUITE_APP_NAME="meet"
|
||||
scalingo env-set LASUITE_BACKEND_DIR="."
|
||||
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
|
||||
scalingo env-set LASUITE_NGINX_DIR="."
|
||||
scalingo env-set LASUITE_SCRIPT_POSTCOMPILE="bin/buildpack_postcompile.sh"
|
||||
scalingo env-set LASUITE_SCRIPT_POSTFRONTEND="bin/buildpack_postfrontend.sh"
|
||||
```
|
||||
|
||||
### Database and Cache
|
||||
|
||||
```bash
|
||||
scalingo env-set DATABASE_URL="\$SCALINGO_POSTGRESQL_URL"
|
||||
scalingo env-set REDIS_URL="\$SCALINGO_REDIS_URL"
|
||||
```
|
||||
|
||||
### Django Settings
|
||||
|
||||
```bash
|
||||
scalingo env-set DJANGO_SETTINGS_MODULE="meet.settings"
|
||||
scalingo env-set DJANGO_CONFIGURATION="Production"
|
||||
scalingo env-set DJANGO_SECRET_KEY="<generate-a-secure-secret-key>"
|
||||
scalingo env-set DJANGO_ALLOWED_HOSTS="my-meet-app.osc-fr1.scalingo.io"
|
||||
```
|
||||
|
||||
### OIDC Authentication
|
||||
|
||||
Configure your OIDC provider (e.g., Keycloak, Authentik):
|
||||
|
||||
```bash
|
||||
scalingo env-set OIDC_OP_BASE_URL="https://auth.yourdomain.com/realms/meet"
|
||||
scalingo env-set OIDC_RP_CLIENT_ID="meet-client-id"
|
||||
scalingo env-set OIDC_RP_CLIENT_SECRET="<your-client-secret>"
|
||||
scalingo env-set OIDC_RP_SIGN_ALGO="RS256"
|
||||
```
|
||||
|
||||
### LiveKit Configuration
|
||||
|
||||
Meet requires a LiveKit server for video conferencing:
|
||||
|
||||
```bash
|
||||
scalingo env-set LIVEKIT_API_URL="wss://livekit.yourdomain.com"
|
||||
scalingo env-set LIVEKIT_API_KEY="<your-livekit-api-key>"
|
||||
scalingo env-set LIVEKIT_API_SECRET="<your-livekit-api-secret>"
|
||||
```
|
||||
|
||||
### Email Configuration (Optional)
|
||||
|
||||
For email notifications see https://doc.scalingo.com/platform/app/sending-emails:
|
||||
|
||||
```bash
|
||||
scalingo env-set DJANGO_EMAIL_HOST="smtp.example.org"
|
||||
scalingo env-set DJANGO_EMAIL_PORT="587"
|
||||
scalingo env-set DJANGO_EMAIL_HOST_USER="<smtp-user>"
|
||||
scalingo env-set DJANGO_EMAIL_HOST_PASSWORD="<smtp-password>"
|
||||
scalingo env-set DJANGO_EMAIL_USE_TLS="True"
|
||||
scalingo env-set DJANGO_EMAIL_FROM="meet@yourdomain.com"
|
||||
```
|
||||
|
||||
## Step 4: Deploy
|
||||
|
||||
Deploy your application:
|
||||
|
||||
```bash
|
||||
git push scalingo main
|
||||
```
|
||||
|
||||
The Procfile will automatically:
|
||||
1. Build the frontend (Vite)
|
||||
2. Build the backend (Django)
|
||||
3. Run the post-compile script (cleanup)
|
||||
4. Run the post-frontend script (move assets and prepare for deployment)
|
||||
5. Start Nginx and Gunicorn
|
||||
6. Run django migrations
|
||||
|
||||
## Step 5: Create superuser
|
||||
|
||||
After the first deployment, create an admin user:
|
||||
|
||||
```bash
|
||||
scalingo run python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## Custom Domain (Optional)
|
||||
|
||||
To use a custom domain:
|
||||
|
||||
1. Add the domain in Scalingo dashboard
|
||||
2. Update `DJANGO_ALLOWED_HOSTS` with your custom domain
|
||||
3. Configure your DNS to point to Scalingo
|
||||
|
||||
```bash
|
||||
scalingo domains-add meet.yourdomain.com
|
||||
scalingo env-set DJANGO_ALLOWED_HOSTS="meet.yourdomain.com,my-meet-app.osc-fr1.scalingo.io"
|
||||
```
|
||||
|
||||
## Custom Logo (Optional)
|
||||
|
||||
To use a custom logo, set the `CUSTOM_LOGO_URL` environment variable with an HTTPS URL pointing to an SVG item (max 5MB):
|
||||
|
||||
```bash
|
||||
scalingo env-set CUSTOM_LOGO_URL="https://cdn.yourdomain.com/logo.svg"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check Logs
|
||||
|
||||
```bash
|
||||
scalingo logs --tail
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Build fails**: Check that all required environment variables are set
|
||||
2. **Database connection error**: Verify `DATABASE_URL` is correctly set to `$SCALINGO_POSTGRESQL_URL`
|
||||
3. **Static files not served**: Ensure the buildpack post-frontend script ran successfully
|
||||
4. **OIDC errors**: Verify your OIDC provider configuration and callback URLs
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# Open a console
|
||||
scalingo run bash
|
||||
|
||||
# Restart the app
|
||||
scalingo restart
|
||||
|
||||
# Scale containers
|
||||
scalingo scale web:2
|
||||
|
||||
# One-off command
|
||||
scalingo run python manage.py shell
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
On Scalingo, the application runs as follows:
|
||||
|
||||
1. **Build Phase**: The buildpack compiles both frontend and backend
|
||||
2. **Runtime**:
|
||||
- Nginx serves static files and proxies to the backend
|
||||
- Gunicorn runs the Django WSGI application
|
||||
- Both processes are managed by the `bin/buildpack_start.sh` script
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Scalingo Documentation](https://doc.scalingo.com/)
|
||||
- [Suite Numérique Buildpack](https://github.com/suitenumerique/buildpack)
|
||||
- [Meet Environment Variables](../../src/helm/meet/README.md)
|
||||
- [Django Configurations Documentation](https://django-configurations.readthedocs.io/)
|
||||
@@ -20,7 +20,7 @@ DJANGO_EMAIL_FROM=<your email address>
|
||||
#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"
|
||||
DJANGO_EMAIL_LOGO_IMG="https://${MEET_HOST}/assets/logo-suite-numerique.png"
|
||||
|
||||
# Backend url
|
||||
MEET_BASE_URL="https://${MEET_HOST}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.2.0"
|
||||
version = "1.7.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.3.10",
|
||||
|
||||
@@ -115,6 +115,10 @@ class RoomAdmin(admin.ModelAdmin):
|
||||
list_filter = ["access_level", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||
return super().get_queryset(request).prefetch_related("accesses__user")
|
||||
|
||||
def get_owner(self, obj):
|
||||
"""Return the owner of the room for display in the admin list."""
|
||||
|
||||
@@ -138,6 +142,7 @@ class RecordingAccessInline(admin.TabularInline):
|
||||
|
||||
model = models.RecordingAccess
|
||||
extra = 0
|
||||
autocomplete_fields = ["user"]
|
||||
|
||||
|
||||
@admin.action(description=_("Resend notification to external service"))
|
||||
@@ -207,8 +212,18 @@ class RecordingAdmin(admin.ModelAdmin):
|
||||
"created_at",
|
||||
"worker_id",
|
||||
)
|
||||
list_filter = ["status", "room", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
list_filter = ["created_at"]
|
||||
list_select_related = ("room",)
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"created_at",
|
||||
"options",
|
||||
"mode",
|
||||
"room",
|
||||
"status",
|
||||
"updated_at",
|
||||
"worker_id",
|
||||
)
|
||||
actions = [resend_notification]
|
||||
|
||||
def get_queryset(self, request):
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Throttling modules for the API."""
|
||||
|
||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
|
||||
def sentry_monitoring_throttle_failure(message):
|
||||
"""Log when a failure occurs to detect rate limiting issues."""
|
||||
capture_message(message, "warning")
|
||||
|
||||
|
||||
class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
|
||||
"""Throttle for the monitored scoped rate throttle."""
|
||||
|
||||
|
||||
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room entry"""
|
||||
|
||||
scope = "request_entry"
|
||||
|
||||
|
||||
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||
@@ -10,7 +10,7 @@ from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.text import slugify
|
||||
|
||||
from rest_framework import decorators, mixins, pagination, throttling, viewsets
|
||||
from rest_framework import decorators, mixins, pagination, viewsets
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
@@ -58,7 +58,7 @@ from core.services.room_creation import RoomCreation
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
from . import permissions, serializers
|
||||
from . import permissions, serializers, throttling
|
||||
from .feature_flag import FeatureFlag
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
@@ -191,18 +191,6 @@ class UserViewSet(
|
||||
)
|
||||
|
||||
|
||||
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room entry"""
|
||||
|
||||
scope = "request_entry"
|
||||
|
||||
|
||||
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
@@ -379,7 +367,7 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="request-entry",
|
||||
permission_classes=[],
|
||||
throttle_classes=[RequestEntryAnonRateThrottle],
|
||||
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
|
||||
)
|
||||
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Request entry to a room"""
|
||||
@@ -489,7 +477,7 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="creation-callback",
|
||||
permission_classes=[],
|
||||
throttle_classes=[CreationCallbackAnonRateThrottle],
|
||||
throttle_classes=[throttling.CreationCallbackAnonRateThrottle],
|
||||
)
|
||||
def creation_callback(self, request):
|
||||
"""Retrieve cached room data via an unauthenticated request with a unique ID.
|
||||
|
||||
@@ -10,6 +10,8 @@ import jwt as pyJwt
|
||||
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
from core.models import Application
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,6 +96,18 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
logger.warning("Missing 'client_id' in JWT payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
|
||||
try:
|
||||
application = Application.objects.get(client_id=client_id)
|
||||
except Application.DoesNotExist as e:
|
||||
logger.warning("Application not found: %s", client_id)
|
||||
raise exceptions.AuthenticationFailed("Application not found.") from e
|
||||
|
||||
if not application.active:
|
||||
logger.warning(
|
||||
"Inactive application attempted authentication: %s", client_id
|
||||
)
|
||||
raise exceptions.AuthenticationFailed("Application is disabled.")
|
||||
|
||||
if not is_delegated:
|
||||
logger.warning("Token is not marked as delegated")
|
||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||
|
||||
@@ -33,12 +33,11 @@ class BaseScopePermission(permissions.BasePermission):
|
||||
Raises:
|
||||
PermissionDenied: If required scope is missing from token
|
||||
"""
|
||||
# Get the current action (e.g., 'list', 'create')
|
||||
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
||||
action = getattr(view, "action", None)
|
||||
if not action:
|
||||
raise exceptions.PermissionDenied(
|
||||
"Insufficient permissions. Unknown action."
|
||||
)
|
||||
# DRF routers return a 405 for unsupported methods
|
||||
return True
|
||||
|
||||
required_scope = self.scope_map.get(action)
|
||||
if not required_scope:
|
||||
@@ -57,9 +56,12 @@ class BaseScopePermission(permissions.BasePermission):
|
||||
if isinstance(token_scopes, str):
|
||||
token_scopes = token_scopes.split()
|
||||
|
||||
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
|
||||
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
|
||||
|
||||
if settings.OIDC_RS_SCOPES_PREFIX:
|
||||
token_scopes = [
|
||||
scope.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
|
||||
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
||||
for scope in token_scopes
|
||||
]
|
||||
|
||||
@@ -82,3 +84,23 @@ class HasRequiredRoomScope(BaseScopePermission):
|
||||
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
|
||||
"destroy": models.ApplicationScope.ROOMS_DELETE,
|
||||
}
|
||||
|
||||
|
||||
class RoomPermissions(permissions.BasePermission):
|
||||
"""Permissions applying to the room API endpoint."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Allow access only to authenticated users."""
|
||||
return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Enforce role-based access: read=any role, delete=owner, write=admin or owner."""
|
||||
user = request.user
|
||||
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return obj.has_any_role(user)
|
||||
|
||||
if request.method == "DELETE":
|
||||
return obj.is_owner(user)
|
||||
|
||||
return obj.is_administrator_or_owner(user)
|
||||
|
||||
@@ -5,7 +5,7 @@ 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.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
import jwt
|
||||
@@ -28,7 +28,7 @@ from . import authentication, permissions, serializers
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class ApplicationViewSet(viewsets.GenericViewSet):
|
||||
class ApplicationViewSet(viewsets.ViewSet):
|
||||
"""API endpoints for application authentication and token generation."""
|
||||
|
||||
@decorators.action(
|
||||
@@ -93,7 +93,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(email=email)
|
||||
user = models.User.objects.get(email__iexact=email)
|
||||
except models.User.DoesNotExist as e:
|
||||
if (
|
||||
settings.APPLICATION_ALLOW_USER_CREATION
|
||||
@@ -123,6 +123,10 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
else:
|
||||
raise drf_exceptions.NotFound("User not found.") from e
|
||||
except models.User.MultipleObjectsReturned as e:
|
||||
raise SuspiciousOperation(
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
scope = " ".join(application.scopes or [])
|
||||
@@ -178,7 +182,9 @@ class RoomViewSet(
|
||||
ResourceServerAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
|
||||
api.permissions.IsAuthenticated
|
||||
& permissions.HasRequiredRoomScope
|
||||
& permissions.RoomPermissions
|
||||
]
|
||||
queryset = models.Room.objects.all()
|
||||
serializer_class = serializers.RoomSerializer
|
||||
|
||||
@@ -292,6 +292,10 @@ class Resource(BaseModel):
|
||||
role = RoleChoices.MEMBER
|
||||
return role
|
||||
|
||||
def has_any_role(self, user):
|
||||
"""Check if a user has any role on the resource."""
|
||||
return self.get_role(user) is not None
|
||||
|
||||
def is_administrator_or_owner(self, user):
|
||||
"""
|
||||
Check if a user is administrator or owner of the resource."""
|
||||
|
||||
@@ -102,6 +102,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
|
||||
settings.RECORDING_DOWNLOAD_BASE_URL = None
|
||||
settings.EMAIL_FROM = "notifications@acme.com"
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
Tests for external API /room endpoint
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
# pylint: disable=W0621,C0302
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
import responses
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import (
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -27,12 +27,14 @@ def generate_test_token(user, scopes):
|
||||
now = datetime.now(timezone.utc)
|
||||
scope_string = " ".join(scopes)
|
||||
|
||||
application = ApplicationFactory()
|
||||
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||
"client_id": "test-client-id",
|
||||
"client_id": str(application.client_id),
|
||||
"scope": scope_string,
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
@@ -53,6 +55,23 @@ def test_api_rooms_list_requires_authentication():
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_list_inactive_user(settings):
|
||||
"""List should return 401 if user is inactive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory(is_active=False)
|
||||
RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user account is disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_list_with_valid_token(settings):
|
||||
"""Listing rooms with valid token should succeed."""
|
||||
|
||||
@@ -73,6 +92,24 @@ def test_api_rooms_list_with_valid_token(settings):
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_list_with_no_rooms(settings):
|
||||
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Generate valid token
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 0
|
||||
assert response.data["results"] == []
|
||||
|
||||
|
||||
def test_api_rooms_list_with_expired_token(settings):
|
||||
"""Listing rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
@@ -92,8 +129,8 @@ def test_api_rooms_list_with_expired_token(settings):
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_list_with_invalid_token(settings):
|
||||
"""Listing rooms with invalid token should return 400."""
|
||||
def test_api_rooms_list_with_invalid_rs_token(settings):
|
||||
"""Listing rooms with invalid resource server token should return 400."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
@@ -130,7 +167,27 @@ def test_api_rooms_list_missing_scope(settings):
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
|
||||
assert (
|
||||
"insufficient permissions. required scope: rooms:list"
|
||||
in str(response.data).lower()
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_list_no_scope(settings):
|
||||
"""Listing rooms without any scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Token without scope
|
||||
token = generate_test_token(user, [])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_list_filters_by_user(settings):
|
||||
@@ -144,7 +201,9 @@ def test_api_rooms_list_filters_by_user(settings):
|
||||
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
|
||||
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
|
||||
|
||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
|
||||
token = generate_test_token(
|
||||
user1, [ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
@@ -158,6 +217,84 @@ def test_api_rooms_list_filters_by_user(settings):
|
||||
assert str(room2.id) not in returned_ids
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_requires_authentication(settings):
|
||||
"""Retrieving rooms without authentication should return 401."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory()
|
||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_inactive_user(settings):
|
||||
"""Retrieve should return 401 if user is inactive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory(is_active=False)
|
||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user account is disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_with_expired_token(settings):
|
||||
"""Retrieving rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
# Generate expired token
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "expired" in str(response.data).lower()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_retrieve_with_invalid_rs_token(settings):
|
||||
"""Retrieving rooms with invalid resource server token should return 400."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"active": False,
|
||||
},
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
# Return 400 instead of 401 because ResourceServerAuthentication raises
|
||||
# SuspiciousOperation when the introspected user is not active
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_requires_scope(settings):
|
||||
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
@@ -178,6 +315,24 @@ def test_api_rooms_retrieve_requires_scope(settings):
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_no_scope(settings):
|
||||
"""Retrieving rooms without any scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Token without scope
|
||||
token = generate_test_token(user, [])
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_success(settings):
|
||||
"""Retrieving a room with correct scope should succeed."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
@@ -212,6 +367,132 @@ def test_api_rooms_retrieve_success(settings):
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_success_by_user(settings):
|
||||
"""Retrieve 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)])
|
||||
room4 = RoomFactory(users=[(user1, RoleChoices.ADMIN)])
|
||||
|
||||
token = generate_test_token(
|
||||
user1, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_LIST]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room2.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room3.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room4.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_not_found(settings):
|
||||
"""Retrieving a non-existing room with correct scope should return a 404."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "no room matches the given query." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_requires_authentication(settings):
|
||||
"""Creating rooms without authentication should return 401."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
client = APIClient()
|
||||
response = client.post("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_create_with_expired_token(settings):
|
||||
"""Creating rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_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.post("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "expired" in str(response.data).lower()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_create_with_invalid_rs_token(settings):
|
||||
"""Creating rooms with invalid resource server token should return 400."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"active": False,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
||||
response = client.post("/external-api/v1.0/rooms/")
|
||||
|
||||
# Return 400 instead of 401 because ResourceServerAuthentication raises
|
||||
# SuspiciousOperation when the introspected user is not active
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_create_inactive_user(settings):
|
||||
"""Create should return 401 if user is inactive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory(is_active=False)
|
||||
|
||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user account is disabled" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_requires_scope(settings):
|
||||
"""Creating a room requires ROOMS_CREATE scope."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
@@ -225,18 +506,38 @@ def test_api_rooms_create_requires_scope(settings):
|
||||
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Insufficient permissions. Required scope: rooms:create" in str(
|
||||
response.data
|
||||
assert (
|
||||
"insufficient permissions. required scope: rooms:create"
|
||||
in str(response.data).lower()
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_create_no_scope(settings):
|
||||
"""Creating rooms without any scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Token without scope
|
||||
token = generate_test_token(user, [])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_create_success(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])
|
||||
token = generate_test_token(
|
||||
user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
@@ -245,6 +546,8 @@ def test_api_rooms_create_success(settings):
|
||||
assert response.status_code == 201
|
||||
assert "id" in response.data
|
||||
assert "slug" in response.data
|
||||
assert "name" in response.data
|
||||
assert response.data["name"] == response.data["slug"]
|
||||
|
||||
# Verify room was created with user as owner
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
@@ -252,6 +555,72 @@ def test_api_rooms_create_success(settings):
|
||||
assert room.access_level == "trusted"
|
||||
|
||||
|
||||
def test_api_rooms_create_readonly_enforcement(settings):
|
||||
"""Creating a room succeeds and any provided read-only fields are ignored."""
|
||||
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/",
|
||||
{
|
||||
"id": "fake-id",
|
||||
"slug": "fake-slug",
|
||||
"name": "fake-name",
|
||||
"access_level": "public",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert "slug" in response.data
|
||||
assert response.data["id"] != "fake-id"
|
||||
assert "name" in response.data
|
||||
assert response.data["slug"] != "fake-slug"
|
||||
assert "id" in response.data
|
||||
assert response.data["name"] != "fake-name"
|
||||
|
||||
# Verify room was created with user as owner
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
assert room.access_level == "trusted"
|
||||
|
||||
|
||||
def test_api_rooms_unknown_actions(settings):
|
||||
"""Updating or deleting a room are not supported yet."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(
|
||||
user,
|
||||
[
|
||||
ApplicationScope.ROOMS_RETRIEVE,
|
||||
ApplicationScope.ROOMS_DELETE,
|
||||
ApplicationScope.ROOMS_UPDATE,
|
||||
],
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 405
|
||||
assert 'method "delete" not allowed.' in str(response.data).lower()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 405
|
||||
assert 'method "patch" not allowed.' in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_response_no_url(settings):
|
||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
@@ -290,10 +659,43 @@ def test_api_rooms_response_no_telephony(settings):
|
||||
assert response.data["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_token_scope_case_insensitive(settings):
|
||||
"""Token's scope should be case-insensitive."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
# Generate token with mixed-case scope "Rooms:List" to verify that scope
|
||||
# validation is case-insensitive (should match "rooms:list")
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": str(application.client_id),
|
||||
"scope": "Rooms:List", # Mixed case - should be accepted as "rooms:list"
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_api_rooms_token_without_delegated_flag(settings):
|
||||
"""Token without delegated flag should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
# Generate token without delegated flag
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -302,7 +704,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": "test-client",
|
||||
"client_id": str(application.client_id),
|
||||
"scope": "rooms:list",
|
||||
"user_id": str(user.id),
|
||||
"delegated": False, # Not delegated
|
||||
@@ -318,7 +720,73 @@ def test_api_rooms_token_without_delegated_flag(settings):
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid token type." in str(response.data)
|
||||
assert "invalid token type." in str(response.data).lower()
|
||||
|
||||
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
||||
"""Token signed with an invalid key should defer to the next authentication."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
# Generate token without delegated flag
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": str(application.client_id),
|
||||
"scope": "rooms:list",
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
"invalid-private-key",
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
mock_rs_authenticate.assert_called()
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
|
||||
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
|
||||
"""Token signed with an invalid alg should defer to the next authentication."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_ALG = "RS256"
|
||||
user = UserFactory()
|
||||
|
||||
# Generate token without delegated flag
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": "test-client",
|
||||
"scope": "rooms:list",
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm="HS256", # different value
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
mock_rs_authenticate.assert_called()
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_token_missing_client_id(settings):
|
||||
@@ -348,7 +816,157 @@ def test_api_rooms_token_missing_client_id(settings):
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid token claims." in str(response.data)
|
||||
assert "invalid token claims." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_token_missing_user_id(settings):
|
||||
"""Token without user_id should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
application = ApplicationFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": str(application.client_id),
|
||||
"scope": "rooms:list",
|
||||
"delegated": True,
|
||||
# Missing user_id
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token claims." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_token_invalid_audience(settings):
|
||||
"""Token with an invalid audience should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
application = ApplicationFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": "invalid-audience",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": str(application.client_id),
|
||||
"user_id": str(user.id),
|
||||
"scope": "rooms:list",
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_token_unknown_user(settings):
|
||||
"""Token for unknown user should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
application = ApplicationFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": str(application.client_id),
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"scope": "rooms:list",
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "user not found." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_token_unknown_application(settings):
|
||||
"""Token for unknown application should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": "unknown-client-id",
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"scope": "rooms:list",
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "application not found." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_token_inactive_application(settings):
|
||||
"""Token for inactive application should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
application = ApplicationFactory(active=False)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": str(application.client_id),
|
||||
"user_id": str(uuid.uuid4()),
|
||||
"scope": "rooms:list",
|
||||
"delegated": True,
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "application is disabled." in str(response.data).lower()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@@ -507,7 +1125,7 @@ def test_resource_server_authentication_successful(settings):
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
|
||||
"scope": "openid lasuite_meet lasuite_meet:rooms:list lasuite_meet:rooms:retrieve",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ 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")
|
||||
UserFactory(email="User.Family@example.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
@@ -40,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
"scope": "user.family@example.com",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from socket import gethostbyname, gethostname
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import dj_database_url
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from lasuite.configuration.values import SecretFileValue
|
||||
@@ -92,7 +93,11 @@ class Base(Configuration):
|
||||
|
||||
# Database
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"default": dj_database_url.config()
|
||||
if values.DatabaseURLValue(
|
||||
None, environ_name="DATABASE_URL", environ_prefix=None
|
||||
)
|
||||
else {
|
||||
"ENGINE": values.Value(
|
||||
"django.db.backends.postgresql_psycopg2",
|
||||
environ_name="DB_ENGINE",
|
||||
@@ -292,6 +297,9 @@ class Base(Configuration):
|
||||
),
|
||||
},
|
||||
}
|
||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||
"core.api.throttling.sentry_monitoring_throttle_failure"
|
||||
)
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "Meet API",
|
||||
@@ -336,6 +344,9 @@ class Base(Configuration):
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
"external_home_url": values.Value(
|
||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||
),
|
||||
"use_french_gov_footer": values.BooleanValue(
|
||||
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.2.0"
|
||||
version = "1.7.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -29,6 +29,7 @@ dependencies = [
|
||||
"Brotli==1.2.0",
|
||||
"brevo-python==1.2.0",
|
||||
"celery[redis]==5.5.3",
|
||||
"dj-database-url==3.1.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.0.0",
|
||||
@@ -38,7 +39,7 @@ dependencies = [
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.9",
|
||||
"django==5.2.11",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2024.4.2",
|
||||
|
||||
@@ -5,6 +5,12 @@ server {
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location = /.wellknown/windows-app-web-link {
|
||||
default_type application/json;
|
||||
alias /usr/share/nginx/html/.wellknown/windows-app-web-link;
|
||||
add_header Content-Disposition "attachment; filename=windows-app-web-link";
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
|
||||
Generated
+515
-154
@@ -1,42 +1,42 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.2.0",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.2.0",
|
||||
"version": "1.7.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.30",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
"@livekit/components-react": "2.9.13",
|
||||
"@livekit/components-styles": "1.1.6",
|
||||
"@livekit/track-processors": "0.6.1",
|
||||
"@pandacss/preset-panda": "0.54.0",
|
||||
"@react-aria/toast": "3.0.5",
|
||||
"@react-types/overlays": "3.9.0",
|
||||
"@react-aria/toast": "3.0.10",
|
||||
"@react-types/overlays": "3.9.3",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"crisp-sdk-web": "1.0.27",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.0",
|
||||
"i18next": "25.3.1",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "25.8.4",
|
||||
"i18next-browser-languagedetector": "8.2.0",
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"libphonenumber-js": "1.12.10",
|
||||
"livekit-client": "2.15.7",
|
||||
"posthog-js": "1.256.2",
|
||||
"posthog-js": "1.342.1",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.10.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.1.5",
|
||||
"wouter": "3.7.1"
|
||||
"valtio": "2.3.0",
|
||||
"wouter": "3.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.54.0",
|
||||
@@ -55,7 +55,7 @@
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.6.2",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.0.8",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
@@ -363,9 +363,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1021,9 +1021,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/material-symbols-outlined": {
|
||||
"version": "5.2.30",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-outlined/-/material-symbols-outlined-5.2.30.tgz",
|
||||
"integrity": "sha512-BjSx7nqvISJs2Pjd8sBH583AnD4k6dD4Em7AVISoLXzbX3PIFWAE2GPm13LlCys8u8idkyUd62L8yn6ts6DdbA==",
|
||||
"version": "5.2.34",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-outlined/-/material-symbols-outlined-5.2.34.tgz",
|
||||
"integrity": "sha512-FfJVoKdYlxvIHflVZlrevf6e1WmbJGeAdbcLmOmkftoP2Du0qrRD/SmNchIXijsKXF7j3z1m++e92Qi6/U+jRA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
@@ -1158,9 +1158,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@internationalized/date": {
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
|
||||
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.11.0.tgz",
|
||||
"integrity": "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
@@ -1177,9 +1177,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@internationalized/number": {
|
||||
"version": "3.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.3.tgz",
|
||||
"integrity": "sha512-p+Zh1sb6EfrfVaS86jlHGQ9HA66fJhV9x5LiE5vCbZtXEHAuhcmUZUdZ4WrFpUBfNalr2OkAJI5AcKEQF+Lebw==",
|
||||
"version": "3.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz",
|
||||
"integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
@@ -1354,6 +1354,252 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api-logs": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz",
|
||||
"integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/core": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
|
||||
"integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/exporter-logs-otlp-http": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz",
|
||||
"integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.208.0",
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/otlp-exporter-base": "0.208.0",
|
||||
"@opentelemetry/otlp-transformer": "0.208.0",
|
||||
"@opentelemetry/sdk-logs": "0.208.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/otlp-exporter-base": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz",
|
||||
"integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/otlp-transformer": "0.208.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/otlp-transformer": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz",
|
||||
"integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.208.0",
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0",
|
||||
"@opentelemetry/sdk-logs": "0.208.0",
|
||||
"@opentelemetry/sdk-metrics": "2.2.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.2.0",
|
||||
"protobufjs": "^7.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz",
|
||||
"integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz",
|
||||
"integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-logs": {
|
||||
"version": "0.208.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz",
|
||||
"integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.208.0",
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.4.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-metrics": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz",
|
||||
"integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.9.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
|
||||
"integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/resources": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
|
||||
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.2.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/semantic-conventions": {
|
||||
"version": "1.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz",
|
||||
"integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/config": {
|
||||
"version": "0.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/config/-/config-0.54.0.tgz",
|
||||
@@ -1746,6 +1992,85 @@
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-0.54.0.tgz",
|
||||
"integrity": "sha512-5kspg2UOgFWrawbHeoleZvbZ6id/kIBLHoDeD1CnLbl9fEbZAZ3Avi5Hi1mIFe9E8PFzp9V+N9IfQL7pYhavfA=="
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.20.1.tgz",
|
||||
"integrity": "sha512-uoTmWkYCtLYFpiK37/JCq+BuCA/OZn1qQZn5cPv1EEKt3ni3Zgg48xWCnSEyGFl5KKSXlfCruiRTwnbAtCgrBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.342.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.342.1.tgz",
|
||||
"integrity": "sha512-bcyBdO88FWTkd5AVTa4Nu8T7RfY0WJrG7WMCXum/rcvNjYhS3DmOfKf8o/Bt56vA3J3yeU0vbgrmltYVoTAfaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
|
||||
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
|
||||
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
|
||||
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1",
|
||||
"@protobufjs/inquire": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
|
||||
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@react-aria/autocomplete": {
|
||||
"version": "3.0.0-beta.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/autocomplete/-/autocomplete-3.0.0-beta.5.tgz",
|
||||
@@ -2096,18 +2421,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/i18n": {
|
||||
"version": "3.12.10",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.10.tgz",
|
||||
"integrity": "sha512-1j00soQ2W0nTgzaaIsGFdMF/5aN60AEdCJPhmXGZiuWdWzMxObN9LQ9vdzYPTjTqyqMdSaSp9DZKs5I26Xovpw==",
|
||||
"version": "3.12.15",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.15.tgz",
|
||||
"integrity": "sha512-3CrAN7ORVHrckvTmbPq76jFZabqq+rScosGT5+ElircJ5rF5+JcdT99Hp5Xg6R10jk74e8G3xiqdYsUd+7iJMA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@internationalized/message": "^3.1.8",
|
||||
"@internationalized/number": "^3.6.3",
|
||||
"@internationalized/number": "^3.6.5",
|
||||
"@internationalized/string": "^3.2.7",
|
||||
"@react-aria/ssr": "^3.9.9",
|
||||
"@react-aria/utils": "^3.29.1",
|
||||
"@react-types/shared": "^3.30.0",
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-aria/utils": "^3.33.0",
|
||||
"@react-types/shared": "^3.33.0",
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -2116,15 +2441,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/interactions": {
|
||||
"version": "3.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.3.tgz",
|
||||
"integrity": "sha512-J1bhlrNtjPS/fe5uJQ+0c7/jiXniwa4RQlP+Emjfc/iuqpW2RhbF9ou5vROcLzWIyaW8tVMZ468J68rAs/aZ5A==",
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.0.tgz",
|
||||
"integrity": "sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.9",
|
||||
"@react-aria/utils": "^3.29.1",
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-aria/utils": "^3.33.0",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-types/shared": "^3.30.0",
|
||||
"@react-types/shared": "^3.33.0",
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -2148,15 +2473,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/landmark": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.4.tgz",
|
||||
"integrity": "sha512-1U5ce6cqg1qGbK4M4R6vwrhUrKXuUzReZwHaTrXxEY22IMxKDXIZL8G7pFpcKix2XKqjLZWf+g8ngGuNhtQ2QQ==",
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.9.tgz",
|
||||
"integrity": "sha512-YYyluDBCXupnMh91ccE5g27fczjYmzPebHqTkVYjH4B6k45pOoqsMmWBCMnOTl0qOCeioI+daT8W0MamAZzoSw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/utils": "^3.29.1",
|
||||
"@react-types/shared": "^3.30.0",
|
||||
"@react-aria/utils": "^3.33.0",
|
||||
"@react-types/shared": "^3.33.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
@@ -2457,9 +2782,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/ssr": {
|
||||
"version": "3.9.9",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.9.tgz",
|
||||
"integrity": "sha512-2P5thfjfPy/np18e5wD4WPt8ydNXhij1jwA8oehxZTFqlgVMGXzcWKxTb4RtJrLFsqPO7RUQTiY8QJk0M4Vy2g==",
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
|
||||
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
@@ -2579,18 +2904,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/toast": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.5.tgz",
|
||||
"integrity": "sha512-uhwiZqPy6hqucBUL7z6uUZjAJ/ou3bNdTjZlXS+zbcm+T0dsjKDfzNkaebyZY7AX3cYkFCaRjc3N6omXwoAviw==",
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.10.tgz",
|
||||
"integrity": "sha512-irW5Cr4msbPo4A4ysjT70MDJbpGCe1h9SkFgdYXBPA4Xbi4jRT7TiEZeIS1I7Hsvp6shAK1Ld/m6NBS0b/gyzg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/i18n": "^3.12.10",
|
||||
"@react-aria/interactions": "^3.25.3",
|
||||
"@react-aria/landmark": "^3.0.4",
|
||||
"@react-aria/utils": "^3.29.1",
|
||||
"@react-stately/toast": "^3.1.1",
|
||||
"@react-types/button": "^3.12.2",
|
||||
"@react-types/shared": "^3.30.0",
|
||||
"@react-aria/i18n": "^3.12.15",
|
||||
"@react-aria/interactions": "^3.27.0",
|
||||
"@react-aria/landmark": "^3.0.9",
|
||||
"@react-aria/utils": "^3.33.0",
|
||||
"@react-stately/toast": "^3.1.3",
|
||||
"@react-types/button": "^3.15.0",
|
||||
"@react-types/shared": "^3.33.0",
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -2672,15 +2997,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/utils": {
|
||||
"version": "3.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.29.1.tgz",
|
||||
"integrity": "sha512-yXMFVJ73rbQ/yYE/49n5Uidjw7kh192WNN9PNQGV0Xoc7EJUlSOxqhnpHmYTyO0EotJ8fdM1fMH8durHjUSI8g==",
|
||||
"version": "3.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.0.tgz",
|
||||
"integrity": "sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.9",
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-stately/utils": "^3.10.7",
|
||||
"@react-types/shared": "^3.30.0",
|
||||
"@react-stately/utils": "^3.11.0",
|
||||
"@react-types/shared": "^3.33.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
@@ -3112,13 +3437,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/toast": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.1.1.tgz",
|
||||
"integrity": "sha512-W4a6xcsFt/E+aHmR2eZK+/p7Y5rdyXSCQ5gKSnbck+S3lijEWAyV45Mv8v95CQqu0bQijj6sy2Js1szq10HVwg==",
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.1.3.tgz",
|
||||
"integrity": "sha512-mT9QJKmD523lqFpOp0VWZ6QHZENFK7HrodnNJDVc7g616s5GNmemdlkITV43fSY3tHeThCVvPu+Uzh7RvQ9mpQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
@@ -3170,9 +3495,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/utils": {
|
||||
"version": "3.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.7.tgz",
|
||||
"integrity": "sha512-cWvjGAocvy4abO9zbr6PW6taHgF24Mwy/LbQ4TC4Aq3tKdKDntxyD+sh7AkSRfJRT2ccMVaHVv2+FfHThd3PKQ==",
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz",
|
||||
"integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
@@ -3224,12 +3549,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/button": {
|
||||
"version": "3.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.12.2.tgz",
|
||||
"integrity": "sha512-QLoSCX8E7NFIdkVMa65TPieve0rKeltfcIxiMtrphjfNn+83L0IHMcbhjf4r4W19c/zqGbw3E53Hx8mNukoTUw==",
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.15.0.tgz",
|
||||
"integrity": "sha512-X/K2/Oeuq7Hi8nMIzx4/YlZuvWFiSOHZt27p4HmThCnNO/9IDFPmvPrpkYjWN5eN9Nuk+P5vZUb4A7QJgYpvGA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-types/shared": "^3.30.0"
|
||||
"@react-types/shared": "^3.33.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
@@ -3399,12 +3724,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/overlays": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.0.tgz",
|
||||
"integrity": "sha512-T2DqMcDN5p8vb4vu2igoLrAtuewaNImLS8jsK7th7OjwQZfIWJn5Y45jSxHtXJUddEg1LkUjXYPSXCMerMcULw==",
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.3.tgz",
|
||||
"integrity": "sha512-LzetThNNk8T26pQRbs1I7+isuFhdFYREy7wJCsZmbB0FnZgCukGTfOtThZWv+ry11veyVJiX68jfl4SV6ACTWA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-types/shared": "^3.31.0"
|
||||
"@react-types/shared": "^3.33.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
@@ -3460,9 +3785,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"version": "3.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.0.tgz",
|
||||
"integrity": "sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
@@ -4023,7 +4348,6 @@
|
||||
"version": "22.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.0.tgz",
|
||||
"integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -4062,6 +4386,13 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/symlink-or-copy/-/symlink-or-copy-1.2.2.tgz",
|
||||
"integrity": "sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA=="
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.35.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz",
|
||||
@@ -5089,15 +5420,16 @@
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||
},
|
||||
"node_modules/crisp-sdk-web": {
|
||||
"version": "1.0.25",
|
||||
"resolved": "https://registry.npmjs.org/crisp-sdk-web/-/crisp-sdk-web-1.0.25.tgz",
|
||||
"integrity": "sha512-CWTHFFeHRV0oqiXoPh/aIAKhFs6xcIM4NenGPnClAMCZUDQgQsF1OWmZWmnVNjJriXUmWRgDfeUxcxygS0dCRA=="
|
||||
"version": "1.0.27",
|
||||
"resolved": "https://registry.npmjs.org/crisp-sdk-web/-/crisp-sdk-web-1.0.27.tgz",
|
||||
"integrity": "sha512-aNWR3te65YiaVFu/iwdqOo3cyUBZHUheE4d6EtgQu/T18jh/9SpoYXjXF/OzUD3Cqy0pGryoqtuy5gxD8tqX9Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
||||
"dev": true,
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
@@ -5376,6 +5708,15 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
|
||||
@@ -6565,15 +6906,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/humanize-duration": {
|
||||
"version": "3.33.0",
|
||||
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.0.tgz",
|
||||
"integrity": "sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ==",
|
||||
"license": "Unlicense"
|
||||
"version": "3.33.2",
|
||||
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz",
|
||||
"integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==",
|
||||
"license": "Unlicense",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",
|
||||
"integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==",
|
||||
"version": "25.8.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.4.tgz",
|
||||
"integrity": "sha512-a9A0MnUjKvzjEN/26ZY1okpra9kA8MEwzYEz1BNm+IyxUKPRH6ihf0p7vj8YvULwZHKHl3zkJ6KOt4hewxBecQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6590,7 +6934,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
@@ -7084,8 +7428,7 @@
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
|
||||
},
|
||||
"node_modules/javascript-stringify": {
|
||||
"version": "2.1.0",
|
||||
@@ -7562,6 +7905,12 @@
|
||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/look-it-up": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/look-it-up/-/look-it-up-2.1.0.tgz",
|
||||
@@ -7739,7 +8088,7 @@
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8043,7 +8392,6 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -8069,7 +8417,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
@@ -8116,7 +8464,7 @@
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -8261,33 +8609,31 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.256.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.256.2.tgz",
|
||||
"integrity": "sha512-ypepnUHr33i5a1Uk39mozZXXTENRPC17HCG3WHKK6aRcpNwNs8uEqXaIKICGNM+qre+totKeTgl0WoaUFYmyoQ==",
|
||||
"version": "1.342.1",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.342.1.tgz",
|
||||
"integrity": "sha512-mMnQhWuKj4ejFicLtFzr52InmqploOyW1eInqXBkaVqE1DPhczBDmwsd9MSggY8kv0EXm8zgK+2tzBJUKcX5yg==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.208.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
|
||||
"@opentelemetry/resources": "^2.2.0",
|
||||
"@opentelemetry/sdk-logs": "^0.208.0",
|
||||
"@posthog/core": "1.20.1",
|
||||
"@posthog/types": "1.342.1",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.19.3",
|
||||
"web-vitals": "^4.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rrweb/types": "2.0.0-alpha.17",
|
||||
"rrweb-snapshot": "2.0.0-alpha.17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rrweb/types": {
|
||||
"optional": true
|
||||
},
|
||||
"rrweb-snapshot": {
|
||||
"optional": true
|
||||
}
|
||||
"preact": "^10.28.2",
|
||||
"query-selector-shadow-dom": "^1.0.1",
|
||||
"web-vitals": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.24.0",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.0.tgz",
|
||||
"integrity": "sha512-aK8Cf+jkfyuZ0ZZRG9FbYqwmEiGQ4y/PUO4SuTWoyWL244nZZh7bd5h2APd4rSNDYTBNghg1L+5iJN3Skxtbsw==",
|
||||
"version": "10.28.3",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
||||
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@@ -8303,9 +8649,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -8331,6 +8677,30 @@
|
||||
"node": "10.* || >= 12.*"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.4",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.0",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.0",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-compare": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz",
|
||||
@@ -8346,6 +8716,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/query-selector-shadow-dom": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
|
||||
"integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -8747,17 +9123,6 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-snapshot": {
|
||||
"version": "2.0.0-alpha.17",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.17.tgz",
|
||||
"integrity": "sha512-GBg5pV8LHOTbeVmH2VHLEFR0mc2QpQMzAvcoxEGfPNWgWHc8UvKCyq7pqN1vA+fDZ+yXXbixeO0kB2pzVvFCBw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"postcss": "^8.4.38"
|
||||
}
|
||||
},
|
||||
"node_modules/rsvp": {
|
||||
"version": "4.8.5",
|
||||
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
|
||||
@@ -8915,7 +9280,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
@@ -8927,7 +9291,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -8993,7 +9356,7 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -9540,7 +9903,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
@@ -9614,9 +9976,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz",
|
||||
"integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -9643,9 +10005,9 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/valtio": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.5.tgz",
|
||||
"integrity": "sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.3.0.tgz",
|
||||
"integrity": "sha512-1MfKNcmOIdBSatiJsYgw420n6jnD+jeoI0V+RkOQbCB0ElLh6GKUfPr0hc9uq/KBGeghivDEarRsKFFdSQQnKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"proxy-compare": "^3.0.1"
|
||||
@@ -9931,9 +10293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
|
||||
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
|
||||
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
@@ -9971,7 +10333,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
@@ -10037,9 +10398,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wouter": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.7.1.tgz",
|
||||
"integrity": "sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==",
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.9.0.tgz",
|
||||
"integrity": "sha512-sF/od/PIgqEQBQcrN7a2x3MX6MQE6nW0ygCfy9hQuUkuB28wEZuu/6M5GyqkrrEu9M6jxdkgE12yDFsQMKos4Q==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
|
||||
+11
-11
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
"version": "1.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,35 +13,35 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.30",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
"@livekit/components-react": "2.9.13",
|
||||
"@livekit/components-styles": "1.1.6",
|
||||
"@livekit/track-processors": "0.6.1",
|
||||
"@pandacss/preset-panda": "0.54.0",
|
||||
"@react-aria/toast": "3.0.5",
|
||||
"@react-types/overlays": "3.9.0",
|
||||
"@react-aria/toast": "3.0.10",
|
||||
"@react-types/overlays": "3.9.3",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"crisp-sdk-web": "1.0.27",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.0",
|
||||
"i18next": "25.3.1",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "25.8.4",
|
||||
"i18next-browser-languagedetector": "8.2.0",
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"libphonenumber-js": "1.12.10",
|
||||
"livekit-client": "2.15.7",
|
||||
"posthog-js": "1.256.2",
|
||||
"posthog-js": "1.342.1",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.10.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.1.5",
|
||||
"wouter": "3.7.1"
|
||||
"valtio": "2.3.0",
|
||||
"wouter": "3.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.54.0",
|
||||
@@ -60,7 +60,7 @@
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.6.2",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.0.8",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"packageFamilyName" : "Visio_g3z6ba6vek6vg",
|
||||
"paths" : [ "*" ]
|
||||
}
|
||||
]
|
||||
@@ -17,6 +17,7 @@ export interface ApiConfig {
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
external_home_url?: string
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
custom_css_url?: string
|
||||
|
||||
@@ -192,7 +192,7 @@ export const IntroSlider = () => {
|
||||
<SlideContainer>
|
||||
{SLIDES.map((slide, index) => (
|
||||
<Slide visible={index == slideIndex} key={index}>
|
||||
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
|
||||
<Image src={slide.src} alt="" role="presentation" />
|
||||
<TextAnimation visible={index == slideIndex}>
|
||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||
<Body>{t(`${slide.key}.body`)}</Body>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
@@ -19,6 +19,7 @@ import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePers
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -155,9 +156,34 @@ export const Home = () => {
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
const [redirectFailed, setRedirectFailed] = useState(false)
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
useEffect(() => {
|
||||
const checkSiteAndRedirect = async () => {
|
||||
if (!data?.external_home_url) return
|
||||
if (isLoggedIn === false) {
|
||||
try {
|
||||
await fetch(data.external_home_url, {
|
||||
method: 'HEAD', // Use HEAD to avoid downloading the full page
|
||||
mode: 'no-cors', // Needed for cross-origin requests
|
||||
})
|
||||
window.location.replace(data.external_home_url)
|
||||
} catch (error) {
|
||||
setRedirectFailed(true)
|
||||
console.error('Site is not reachable:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkSiteAndRedirect()
|
||||
}, [isLoggedIn, data])
|
||||
|
||||
if (data?.external_home_url && isLoggedIn == false && !redirectFailed) {
|
||||
return <LoadingScreen header={false} footer={false} delay={0} />
|
||||
}
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen>
|
||||
|
||||
@@ -25,15 +25,27 @@ export const TermsOfServiceRoute = () => {
|
||||
{/* Article 2 */}
|
||||
<H lvl={2}>{t('articles.article2.title')}</H>
|
||||
<P>{t('articles.article2.content')}</P>
|
||||
<P>{t('articles.article2.purposes')}</P>
|
||||
{ensureArray(
|
||||
t('articles.article2.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Article 3 */}
|
||||
<H lvl={2}>{t('articles.article3.title')}</H>
|
||||
<P>{t('articles.article3.definition')}</P>
|
||||
{ensureArray(
|
||||
t('articles.article3.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Article 4 */}
|
||||
<H lvl={2}>{t('articles.article4.title')}</H>
|
||||
<P>{t('articles.article4.content')}</P>
|
||||
<P>{t('articles.article4.definition')}</P>
|
||||
|
||||
{/* Article 5 */}
|
||||
<H lvl={2} margin={false}>
|
||||
@@ -62,6 +74,7 @@ export const TermsOfServiceRoute = () => {
|
||||
__html: t('articles.article5.sections.section1.paragraph3'),
|
||||
}}
|
||||
></P>
|
||||
<P>{t('articles.article5.sections.section1.paragraph4')}</P>
|
||||
|
||||
{/* Section 5.2 */}
|
||||
<H lvl={3} bold>
|
||||
@@ -112,7 +125,6 @@ export const TermsOfServiceRoute = () => {
|
||||
|
||||
{/* Article 7 */}
|
||||
<H lvl={2}>{t('articles.article7.title')}</H>
|
||||
<P>{t('articles.article7.content')}</P>
|
||||
|
||||
{/* Section 7.1 */}
|
||||
<H lvl={3} bold>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo, useRef, useState, useEffect } from 'react'
|
||||
import { useMemo, useRef, useEffect } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import {
|
||||
RecordingMode,
|
||||
@@ -13,6 +13,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useRoomMetadata } from '../hooks/useRoomMetadata'
|
||||
import { RecordingStatusIcon } from './RecordingStatusIcon'
|
||||
import { useIsRecording } from '@livekit/components-react'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
export const RecordingStateToast = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
@@ -21,8 +22,8 @@ export const RecordingStateToast = () => {
|
||||
|
||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||
|
||||
const [srMessage, setSrMessage] = useState('')
|
||||
const lastKeyRef = useRef('')
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
@@ -76,16 +77,9 @@ export const RecordingStateToast = () => {
|
||||
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)
|
||||
announce(message)
|
||||
}
|
||||
}, [key, t])
|
||||
}, [announce, key, t])
|
||||
|
||||
if (!key) return null
|
||||
|
||||
@@ -95,15 +89,6 @@ export const RecordingStateToast = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Screen reader only message to announce state changes once */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{srMessage}
|
||||
</div>
|
||||
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -104,14 +104,12 @@ export const RecordingDownload = () => {
|
||||
{t('success.title')}
|
||||
</H>
|
||||
<Text centered margin="md" wrap={'balance'}>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('success.body', {
|
||||
room: data.room.name,
|
||||
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{t('success.body', {
|
||||
room: data.room.name,
|
||||
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{configData?.recording?.expiration_days && (
|
||||
<>
|
||||
|
||||
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
square
|
||||
size={'sm'}
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={t('copyUrl')}
|
||||
tooltip={t('copyUrl')}
|
||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine aria-hidden="true" />
|
||||
@@ -138,11 +138,12 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
{formatPinCode(roomData?.pin_code)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'secondaryText'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={t('copy')}
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
@@ -173,7 +174,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
fullWidth
|
||||
aria-label={t('copy')}
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
|
||||
@@ -446,16 +446,14 @@ export const Join = ({
|
||||
type="text"
|
||||
onChange={saveUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
id="input-name"
|
||||
defaultValue={username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
labelProps={{
|
||||
center: true,
|
||||
}}
|
||||
autoComplete="name"
|
||||
maxLength={50}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import { Button, Dialog, H, P } from '@/primitives'
|
||||
import { A, Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import humanizeDuration from 'humanize-duration'
|
||||
import i18n from 'i18next'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
|
||||
const FINAL_COUNTDOWN_SECONDS = 10
|
||||
|
||||
export const IsIdleDisconnectModal = () => {
|
||||
const connectionObserverSnap = useSnapshot(connectionObserverStore)
|
||||
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
|
||||
const lastAnnouncementRef = useRef<number | null>(null)
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
|
||||
@@ -35,10 +43,42 @@ export const IsIdleDisconnectModal = () => {
|
||||
}
|
||||
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
|
||||
|
||||
const minutes = Math.floor(timeRemaining / 1000 / 60)
|
||||
const seconds = (timeRemaining / 1000) % 60
|
||||
useEffect(() => {
|
||||
if (!connectionObserverSnap.isIdleDisconnectModalOpen) {
|
||||
lastAnnouncementRef.current = null
|
||||
}
|
||||
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
|
||||
|
||||
const remainingSeconds = Math.floor(timeRemaining / 1000)
|
||||
const minutes = Math.floor(remainingSeconds / 60)
|
||||
const seconds = remainingSeconds % 60
|
||||
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
|
||||
|
||||
const shouldAnnounce =
|
||||
COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
|
||||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
|
||||
|
||||
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
|
||||
lastAnnouncementRef.current = remainingSeconds
|
||||
const message = t('countdownAnnouncement', {
|
||||
duration: humanizeDuration(remainingSeconds * 1000, {
|
||||
language: i18n.language,
|
||||
round: false,
|
||||
largest: 2,
|
||||
}),
|
||||
})
|
||||
announce(message, 'assertive', 'idle')
|
||||
}
|
||||
}, [
|
||||
announce,
|
||||
connectionObserverSnap.isIdleDisconnectModalOpen,
|
||||
remainingSeconds,
|
||||
t,
|
||||
])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
|
||||
@@ -52,6 +92,7 @@ export const IsIdleDisconnectModal = () => {
|
||||
{({ close }) => {
|
||||
return (
|
||||
<div>
|
||||
<ScreenReaderAnnouncer channel="idle" />
|
||||
<div
|
||||
className={css({
|
||||
height: '50px',
|
||||
@@ -65,6 +106,7 @@ export const IsIdleDisconnectModal = () => {
|
||||
color: 'blue.800',
|
||||
margin: 'auto',
|
||||
})}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{formattedTime}
|
||||
</div>
|
||||
@@ -78,7 +120,19 @@ export const IsIdleDisconnectModal = () => {
|
||||
}),
|
||||
})}
|
||||
</P>
|
||||
<P>{t('settings')}</P>
|
||||
<P>
|
||||
{t('settingsPrefix')}{' '}
|
||||
<A
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = false
|
||||
openSettingsDialog(SettingsDialogExtendedKey.GENERAL)
|
||||
}}
|
||||
>
|
||||
{t('settingsLink')}
|
||||
</A>
|
||||
{t('settingsSuffix')}
|
||||
</P>
|
||||
<HStack marginTop="2rem">
|
||||
<Button
|
||||
onPress={() => {
|
||||
|
||||
@@ -5,6 +5,10 @@ import { css } from '@/styled-system/css'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||
import { accessibilityStore } from '@/stores/accessibility'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
export const ANIMATION_DURATION = 3000
|
||||
export const ANIMATION_DISTANCE = 300
|
||||
@@ -140,11 +144,41 @@ export function ReactionPortal({
|
||||
)
|
||||
}
|
||||
|
||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
|
||||
reactions.map((instance) => (
|
||||
<ReactionPortal
|
||||
key={instance.id}
|
||||
emoji={instance.emoji}
|
||||
participant={instance.participant}
|
||||
/>
|
||||
))
|
||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const { announceReactions } = useSnapshot(accessibilityStore)
|
||||
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const latestReaction =
|
||||
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!announceReactions) {
|
||||
return
|
||||
}
|
||||
if (!latestReaction) return
|
||||
const isNewReaction = latestReaction.id !== lastAnnouncedId
|
||||
if (!isNewReaction) return
|
||||
|
||||
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
|
||||
const participantName = latestReaction.participant?.isLocal
|
||||
? t('you')
|
||||
: latestReaction.participant?.name?.trim() ||
|
||||
t('someone', { defaultValue: 'Someone' })
|
||||
announce(t('announce', { name: participantName, emoji: emojiLabel }))
|
||||
setLastAnnouncedId(latestReaction.id)
|
||||
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
{reactions.map((instance) => (
|
||||
<ReactionPortal
|
||||
key={instance.id}
|
||||
emoji={instance.emoji}
|
||||
participant={instance.participant}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,35 +7,46 @@ import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, useEffect, useRef } from 'react'
|
||||
import { Chat } from '../prefabs/Chat'
|
||||
import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
import { Tools } from './Tools'
|
||||
import { Info } from './Info'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
const SIDE_PANEL_HEADING_ID = 'side-panel-heading'
|
||||
const SIDE_PANEL_CLOSE_ID = 'side-panel-close'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
ariaLabel: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
isClosed: boolean
|
||||
closeButtonTooltip: string
|
||||
isSubmenu: boolean
|
||||
onBack: () => void
|
||||
backButtonLabel: string
|
||||
}
|
||||
|
||||
const StyledSidePanel = ({
|
||||
title,
|
||||
ariaLabel,
|
||||
children,
|
||||
onClose,
|
||||
isClosed,
|
||||
closeButtonTooltip,
|
||||
isSubmenu = false,
|
||||
onBack,
|
||||
backButtonLabel,
|
||||
}: StyledSidePanelProps) => (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- role="dialog" makes this interactive
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-labelledby={!isClosed ? SIDE_PANEL_HEADING_ID : undefined}
|
||||
aria-hidden={isClosed || undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}}
|
||||
className={css({
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
@@ -60,54 +71,57 @@ const StyledSidePanel = ({
|
||||
style={{
|
||||
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
||||
}}
|
||||
aria-hidden={isClosed}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
style={{
|
||||
paddingLeft: '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{isSubmenu && (
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
size="sm"
|
||||
square
|
||||
className={css({ marginRight: '0.5rem' })}
|
||||
onPress={onBack}
|
||||
<HStack alignItems="center">
|
||||
{isSubmenu && (
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
size="sm"
|
||||
square
|
||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
||||
aria-label={backButtonLabel}
|
||||
onPress={onBack}
|
||||
>
|
||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<Heading
|
||||
id={SIDE_PANEL_HEADING_ID}
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
style={{
|
||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<RiArrowLeftLine size={20} />
|
||||
</Button>
|
||||
)}
|
||||
{title}
|
||||
</Heading>
|
||||
<Div
|
||||
position="absolute"
|
||||
top="5"
|
||||
right="5"
|
||||
style={{
|
||||
display: isClosed ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={onClose}
|
||||
aria-label={closeButtonTooltip}
|
||||
tooltip={closeButtonTooltip}
|
||||
{title}
|
||||
</Heading>
|
||||
</HStack>
|
||||
<Div
|
||||
position="absolute"
|
||||
top="5"
|
||||
right="5"
|
||||
style={{
|
||||
display: isClosed ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
{children}
|
||||
<Button
|
||||
id={SIDE_PANEL_CLOSE_ID}
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={onClose}
|
||||
aria-label={closeButtonTooltip}
|
||||
tooltip={closeButtonTooltip}
|
||||
>
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
{children}
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -145,19 +159,57 @@ export const SidePanel = () => {
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// The aside stays mounted (CSS slide + keepAlive), so we manually handle
|
||||
// auto-focus on open and restore focus on close (via handleClose).
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSidePanelOpen) return
|
||||
const active = document.activeElement as HTMLElement
|
||||
// Menu items render as DIVs that unmount when the menu closes — resolve to the menu trigger
|
||||
triggerRef.current =
|
||||
active?.tagName === 'DIV'
|
||||
? (document.querySelector<HTMLElement>('#room-options-trigger') ??
|
||||
active)
|
||||
: active
|
||||
requestAnimationFrame(() => {
|
||||
const closeBtn = document.getElementById(SIDE_PANEL_CLOSE_ID)
|
||||
// Skip if a child panel already moved focus inside (e.g. Chat input)
|
||||
if (closeBtn?.closest('aside')?.contains(document.activeElement)) return
|
||||
closeBtn?.focus({ preventScroll: true })
|
||||
})
|
||||
}, [isSidePanelOpen])
|
||||
|
||||
const handleClose = () => {
|
||||
const trigger = triggerRef.current
|
||||
triggerRef.current = null
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
// Double RAF: first lets React re-render, second lets FocusScope release containment
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (trigger?.isConnected) {
|
||||
trigger.focus({ preventScroll: true })
|
||||
} else {
|
||||
document
|
||||
.querySelector<HTMLElement>('#room-options-trigger')
|
||||
?.focus({ preventScroll: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||
ariaLabel={t('ariaLabel')}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
}}
|
||||
onClose={handleClose}
|
||||
closeButtonTooltip={t('closeButton', {
|
||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||
})}
|
||||
isClosed={!isSidePanelOpen}
|
||||
isSubmenu={isSubPanelOpen}
|
||||
backButtonLabel={t('backToTools')}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Button as RACButton } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactNode } from 'react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import {
|
||||
useIsRecordingModeEnabled,
|
||||
RecordingMode,
|
||||
@@ -95,26 +94,10 @@ const ToolButton = ({
|
||||
|
||||
export const Tools = () => {
|
||||
const { data } = useConfig()
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId } =
|
||||
useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
|
||||
// Restore focus to the element that opened the Tools panel
|
||||
// following the same pattern as Chat.
|
||||
useRestoreFocus(isToolsOpen, {
|
||||
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
|
||||
// find the "more options" button ("Plus d'options") that opened the menu
|
||||
resolveTrigger: (activeEl) => {
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
||||
}
|
||||
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
||||
return activeEl
|
||||
},
|
||||
restoreFocusRaf: true,
|
||||
preventScroll: true,
|
||||
})
|
||||
|
||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||
RecordingMode.Transcript
|
||||
)
|
||||
|
||||
+1
-3
@@ -11,9 +11,7 @@ import {
|
||||
ProcessorType,
|
||||
} from '.'
|
||||
|
||||
export class UnifiedBackgroundTrackProcessor
|
||||
implements BackgroundProcessorInterface
|
||||
{
|
||||
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
||||
processor: ProcessorWrapper<BackgroundOptions>
|
||||
opts: BackgroundOptions
|
||||
processorType: ProcessorType
|
||||
|
||||
@@ -13,8 +13,7 @@ export interface ProcessorSerialized {
|
||||
options: BackgroundOptions
|
||||
}
|
||||
|
||||
export interface BackgroundProcessorInterface
|
||||
extends TrackProcessor<Track.Kind> {
|
||||
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
|
||||
update(opts: BackgroundOptions): Promise<void>
|
||||
options: BackgroundOptions
|
||||
clone(): BackgroundProcessorInterface
|
||||
|
||||
@@ -80,10 +80,12 @@ export const ChatInput = ({
|
||||
<TextArea
|
||||
ref={inputRef}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key !== 'Escape') e.stopPropagation()
|
||||
submitOnEnter(e)
|
||||
}}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key !== 'Escape') e.stopPropagation()
|
||||
}}
|
||||
placeholder={t('textArea.placeholder')}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ToggleButton } from '@/primitives'
|
||||
import { chatStore } from '@/stores/chat'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
export const ChatToggle = ({
|
||||
onPress,
|
||||
@@ -18,6 +19,11 @@ export const ChatToggle = ({
|
||||
const { isChatOpen, toggleChat } = useSidePanel()
|
||||
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'toggle-chat',
|
||||
handler: toggleChat,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
|
||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||
|
||||
type ToggleDeviceStyleProps = {
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
@@ -88,12 +89,14 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
const deviceShortcut = useDeviceShortcut(kind)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: deviceShortcut,
|
||||
id: deviceShortcut?.id,
|
||||
handler: async () => await toggle(),
|
||||
isDisabled: cannotUseDevice,
|
||||
})
|
||||
|
||||
const pushToTalkShortcut = getShortcutDescriptorById('push-to-talk')
|
||||
useLongPress({
|
||||
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
|
||||
keyCode: kind === 'audioinput' ? pushToTalkShortcut?.code : undefined,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
isDisabled: cannotUseDevice,
|
||||
@@ -103,7 +106,9 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
const label = t(enabled ? 'disable' : 'enable', {
|
||||
keyPrefix: `selectDevice.${kind}`,
|
||||
})
|
||||
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
|
||||
return deviceShortcut?.shortcut
|
||||
? appendShortcutLabel(label, deviceShortcut.shortcut)
|
||||
: label
|
||||
}, [enabled, kind, deviceShortcut, t])
|
||||
|
||||
const Icon =
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
closeLowerHandToasts,
|
||||
showLowerHandToast,
|
||||
} from '@/features/notifications/utils'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
const SPEAKING_DETECTION_DELAY = 3000
|
||||
|
||||
@@ -33,6 +34,16 @@ export const HandToggle = () => {
|
||||
closeLowerHandToasts()
|
||||
}, [isHandRaised])
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleRaisedHand()
|
||||
resetToastState()
|
||||
}
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'raise-hand',
|
||||
handler: handleToggle,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
|
||||
|
||||
@@ -68,10 +79,7 @@ export const HandToggle = () => {
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isHandRaised}
|
||||
onPress={() => {
|
||||
toggleRaisedHand()
|
||||
resetToastState()
|
||||
}}
|
||||
onPress={handleToggle}
|
||||
data-attr={`controls-hand-${tooltipLabel}`}
|
||||
>
|
||||
<RiHand />
|
||||
|
||||
+27
-15
@@ -1,10 +1,12 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
export const ParticipantsToggle = ({
|
||||
onPress,
|
||||
@@ -19,11 +21,18 @@ export const ParticipantsToggle = ({
|
||||
*/
|
||||
const participants = useParticipants()
|
||||
const numParticipants = participants?.length
|
||||
const announcedCount =
|
||||
numParticipants && numParticipants > 0 ? numParticipants : 1
|
||||
|
||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
|
||||
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'toggle-participants',
|
||||
handler: toggleParticipants,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -31,21 +40,24 @@ export const ParticipantsToggle = ({
|
||||
display: 'inline-block',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryTextDark"
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isParticipantsOpen}
|
||||
onPress={(e) => {
|
||||
toggleParticipants()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-participants-${tooltipLabel}`}
|
||||
{...props}
|
||||
>
|
||||
<RiGroupLine />
|
||||
</ToggleButton>
|
||||
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryTextDark"
|
||||
aria-label={`${t(tooltipLabel)}. ${t('count', {
|
||||
count: announcedCount,
|
||||
})}.`}
|
||||
isSelected={isParticipantsOpen}
|
||||
onPress={(e) => {
|
||||
toggleParticipants()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-participants-${tooltipLabel}`}
|
||||
{...props}
|
||||
>
|
||||
<RiGroupLine />
|
||||
</ToggleButton>
|
||||
</VisualOnlyTooltip>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
ANIMATION_DURATION,
|
||||
ReactionPortals,
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { Toolbar as RACToolbar } from 'react-aria-components'
|
||||
import { Participant } from 'livekit-client'
|
||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||
@@ -40,6 +42,11 @@ export const ReactionsToggle = () => {
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'reaction',
|
||||
handler: () => setIsVisible((prev) => !prev),
|
||||
})
|
||||
|
||||
const sendReaction = async (emoji: string) => {
|
||||
const encoder = new TextEncoder()
|
||||
const payload: NotificationPayload = {
|
||||
@@ -145,7 +152,7 @@ export const ReactionsToggle = () => {
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => debouncedSendReaction(emoji)}
|
||||
aria-label={t('send', { emoji })}
|
||||
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
square
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
import React from 'react'
|
||||
|
||||
/** @public */
|
||||
export interface AllowMediaPlaybackProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
export interface AllowMediaPlaybackProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
label?: string
|
||||
}
|
||||
|
||||
|
||||
+46
-34
@@ -18,6 +18,7 @@ import { Loader } from '@/primitives/Loader'
|
||||
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
||||
import { FunnyEffects } from './FunnyEffects'
|
||||
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
@@ -56,11 +57,11 @@ export const EffectsConfiguration = ({
|
||||
const [processorPending, setProcessorPending] = useState(false)
|
||||
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
||||
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
|
||||
const [blurStatusMessage, setBlurStatusMessage] = useState('')
|
||||
const blurAnnouncementTimeout = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null
|
||||
)
|
||||
const blurAnnouncementId = useRef(0)
|
||||
const announce = useScreenReaderAnnounce()
|
||||
const effectAnnouncementTimeout = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null)
|
||||
const effectAnnouncementId = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
@@ -89,27 +90,24 @@ export const EffectsConfiguration = ({
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (blurAnnouncementTimeout.current) {
|
||||
clearTimeout(blurAnnouncementTimeout.current)
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const announceBlurStatusMessage = (message: string) => {
|
||||
blurAnnouncementId.current += 1
|
||||
const currentId = blurAnnouncementId.current
|
||||
const announceEffectStatusMessage = (message: string) => {
|
||||
effectAnnouncementId.current += 1
|
||||
const currentId = effectAnnouncementId.current
|
||||
|
||||
if (blurAnnouncementTimeout.current) {
|
||||
clearTimeout(blurAnnouncementTimeout.current)
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
|
||||
// Clear the region first so screen readers drop queued announcements.
|
||||
setBlurStatusMessage('')
|
||||
|
||||
blurAnnouncementTimeout.current = setTimeout(() => {
|
||||
if (currentId !== blurAnnouncementId.current) return
|
||||
setBlurStatusMessage(message)
|
||||
effectAnnouncementTimeout.current = setTimeout(() => {
|
||||
if (currentId !== effectAnnouncementId.current) return
|
||||
announce(message)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
@@ -118,25 +116,42 @@ export const EffectsConfiguration = ({
|
||||
onSubmit?.(undefined)
|
||||
}
|
||||
|
||||
const updateBlurStatusMessage = (
|
||||
const getVirtualBackgroundName = (imagePath?: string) => {
|
||||
if (!imagePath) return ''
|
||||
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
|
||||
if (!match) return ''
|
||||
const index = Number(match[1]) - 1
|
||||
if (Number.isNaN(index)) return ''
|
||||
return t(`virtual.descriptions.${index}`)
|
||||
}
|
||||
|
||||
const updateEffectStatusMessage = (
|
||||
type: ProcessorType,
|
||||
options: BackgroundOptions,
|
||||
wasSelectedBeforeToggle: boolean
|
||||
) => {
|
||||
if (type !== ProcessorType.BLUR) return
|
||||
|
||||
let message = ''
|
||||
|
||||
if (wasSelectedBeforeToggle) {
|
||||
message = t('blur.status.none')
|
||||
} else if (options.blurRadius === BlurRadius.LIGHT) {
|
||||
message = t('blur.status.light')
|
||||
} else if (options.blurRadius === BlurRadius.NORMAL) {
|
||||
message = t('blur.status.strong')
|
||||
announceEffectStatusMessage(t('blur.status.none'))
|
||||
return
|
||||
}
|
||||
|
||||
if (message) {
|
||||
announceBlurStatusMessage(message)
|
||||
if (type === ProcessorType.BLUR) {
|
||||
const message =
|
||||
options.blurRadius === BlurRadius.LIGHT
|
||||
? t('blur.status.light')
|
||||
: t('blur.status.strong')
|
||||
announceEffectStatusMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === ProcessorType.VIRTUAL) {
|
||||
const backgroundName = getVirtualBackgroundName(options.imagePath)
|
||||
if (backgroundName) {
|
||||
announceEffectStatusMessage(
|
||||
`${t('virtual.selectedLabel')} ${backgroundName}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +215,7 @@ export const EffectsConfiguration = ({
|
||||
onSubmit?.(processor)
|
||||
}
|
||||
|
||||
updateBlurStatusMessage(type, options, wasSelectedBeforeToggle)
|
||||
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
} finally {
|
||||
@@ -406,9 +421,6 @@ export const EffectsConfiguration = ({
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{blurStatusMessage}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -11,8 +11,7 @@ const ASPECT_RATIO = 16 / 10
|
||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||
|
||||
/** @public */
|
||||
export interface CarouselLayoutProps
|
||||
extends React.HTMLAttributes<HTMLMediaElement> {
|
||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
children: React.ReactNode
|
||||
/** Place the tiles vertically or horizontally next to each other.
|
||||
|
||||
@@ -13,7 +13,8 @@ import { PaginationControl } from '../controls/PaginationControl'
|
||||
|
||||
/** @public */
|
||||
export interface GridLayoutProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
Pick<UseParticipantsOptions, 'updateOnlyOn'> {
|
||||
children: React.ReactNode
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
|
||||
@@ -12,6 +12,6 @@ export function useCanPublishTrack(trackSource: TrackSource): boolean {
|
||||
|
||||
return Boolean(
|
||||
permissions?.canPublish &&
|
||||
permissions?.canPublishSources?.includes(trackSource)
|
||||
permissions?.canPublishSources?.includes(trackSource)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
import {
|
||||
getShortcutDescriptorById,
|
||||
ShortcutDescriptor,
|
||||
} from '@/features/shortcuts/catalog'
|
||||
|
||||
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
|
||||
return useMemo<Shortcut | undefined>(() => {
|
||||
return useMemo<ShortcutDescriptor | undefined>(() => {
|
||||
switch (kind) {
|
||||
case 'audioinput':
|
||||
return {
|
||||
key: 'd',
|
||||
ctrlKey: true,
|
||||
}
|
||||
return getShortcutDescriptorById('toggle-microphone')
|
||||
case 'videoinput':
|
||||
return {
|
||||
key: 'e',
|
||||
ctrlKey: true,
|
||||
}
|
||||
return getShortcutDescriptorById('toggle-camera')
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -15,11 +15,9 @@ import { ChatEntry } from '../components/chat/Entry'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
|
||||
export interface ChatProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
ChatOptions {}
|
||||
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
|
||||
|
||||
/**
|
||||
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
|
||||
@@ -37,18 +35,12 @@ export function Chat({ ...props }: ChatProps) {
|
||||
const { isChatOpen } = useSidePanel()
|
||||
const chatSnap = useSnapshot(chatStore)
|
||||
|
||||
// Keep track of the element that opened the chat so we can restore focus
|
||||
// when the chat panel is closed.
|
||||
useRestoreFocus(isChatOpen, {
|
||||
// Avoid layout "jump" during the side panel slide-in animation.
|
||||
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
|
||||
onOpened: () => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
})
|
||||
},
|
||||
preventScroll: true,
|
||||
})
|
||||
React.useEffect(() => {
|
||||
if (!isChatOpen) return
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
})
|
||||
}, [isChatOpen])
|
||||
|
||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||
const participants = useParticipants()
|
||||
|
||||
@@ -22,7 +22,7 @@ export function DesktopControlBar({
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: { key: 'F2' },
|
||||
id: 'focus-toolbar',
|
||||
handler: () => {
|
||||
const root = desktopControlBarEl.current
|
||||
if (!root) return
|
||||
|
||||
@@ -5,9 +5,8 @@ import {
|
||||
isWeb,
|
||||
log,
|
||||
} from '@livekit/components-core'
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Participant, RoomEvent, Track } from 'livekit-client'
|
||||
import React, { useCallback, useRef, useState, useEffect } from 'react'
|
||||
import {
|
||||
ConnectionStateToast,
|
||||
FocusLayoutContainer,
|
||||
@@ -16,7 +15,9 @@ import {
|
||||
usePinnedTracks,
|
||||
useTracks,
|
||||
useCreateLayoutContext,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ControlBar } from './ControlBar/ControlBar'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
@@ -37,6 +38,8 @@ import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
||||
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
||||
import { GridLayout } from '../components/layout/GridLayout'
|
||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
@@ -63,8 +66,7 @@ const LayoutWrapper = styled(
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface VideoConferenceProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** @alpha */
|
||||
SettingsComponent?: React.ComponentType
|
||||
}
|
||||
@@ -89,7 +91,22 @@ export interface VideoConferenceProps
|
||||
*/
|
||||
export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
const lastAutoFocusedScreenShareTrack =
|
||||
React.useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||
useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
||||
const { t: tRooms } = useTranslation('rooms')
|
||||
const room = useRoomContext()
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const getAnnouncementName = useCallback(
|
||||
(participant?: Participant | null) => {
|
||||
if (!participant) return tRooms('participants.unknown')
|
||||
return participant.isLocal
|
||||
? tRooms('participants.you')
|
||||
: getParticipantName(participant)
|
||||
},
|
||||
[tRooms]
|
||||
)
|
||||
|
||||
useConnectionObserver()
|
||||
useVideoResolutionSubscription()
|
||||
@@ -115,9 +132,62 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
(track) => !isEqualTrackRef(track, focusTrack)
|
||||
)
|
||||
|
||||
// handle pin announcements
|
||||
|
||||
useEffect(() => {
|
||||
const participant = focusTrack?.participant
|
||||
|
||||
// 1. unpin
|
||||
if (!participant) {
|
||||
if (!lastPinnedParticipantIdentityRef.current) return
|
||||
|
||||
const lastIdentity = lastPinnedParticipantIdentityRef.current
|
||||
const lastParticipant =
|
||||
room.localParticipant.identity === lastIdentity
|
||||
? room.localParticipant
|
||||
: room.remoteParticipants.get(lastIdentity)
|
||||
const announcementName = getAnnouncementName(lastParticipant)
|
||||
|
||||
announce(
|
||||
lastParticipant?.isLocal
|
||||
? t('self.unpin')
|
||||
: t('unpin', {
|
||||
name: announcementName,
|
||||
})
|
||||
)
|
||||
|
||||
lastPinnedParticipantIdentityRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// 2. same pin → do nothing
|
||||
if (lastPinnedParticipantIdentityRef.current === participant.identity) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. new pin
|
||||
const participantName = participant.isLocal
|
||||
? tRooms('participants.you')
|
||||
: getParticipantName(participant)
|
||||
|
||||
lastPinnedParticipantIdentityRef.current = participant.identity
|
||||
|
||||
announce(
|
||||
participant.isLocal ? t('self.pin') : t('pin', { name: participantName })
|
||||
)
|
||||
}, [
|
||||
announce,
|
||||
focusTrack,
|
||||
getAnnouncementName,
|
||||
room.localParticipant,
|
||||
room.remoteParticipants,
|
||||
t,
|
||||
tRooms,
|
||||
])
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||
if (
|
||||
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
||||
|
||||
@@ -9,8 +9,7 @@ import NoiseSuppressorWorklet from '@timephy/rnnoise-wasm/NoiseSuppressorWorklet
|
||||
// and suspend/resume it as needed to manage audio state
|
||||
let audioContext: AudioContext
|
||||
|
||||
export interface AudioProcessorInterface
|
||||
extends TrackProcessor<Track.Kind.Audio> {
|
||||
export interface AudioProcessorInterface extends TrackProcessor<Track.Kind.Audio> {
|
||||
name: string
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const getEmojiLabel = (
|
||||
emoji: string,
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
) => {
|
||||
const emojiLabels: Record<string, string> = {
|
||||
'thumbs-up': t('emojis.thumbs-up', { defaultValue: 'thumbs up' }),
|
||||
'thumbs-down': t('emojis.thumbs-down', { defaultValue: 'thumbs down' }),
|
||||
'clapping-hands': t('emojis.clapping-hands', {
|
||||
defaultValue: 'clapping hands',
|
||||
}),
|
||||
'red-heart': t('emojis.red-heart', { defaultValue: 'red heart' }),
|
||||
'face-with-tears-of-joy': t('emojis.face-with-tears-of-joy', {
|
||||
defaultValue: 'face with tears of joy',
|
||||
}),
|
||||
'face-with-open-mouth': t('emojis.face-with-open-mouth', {
|
||||
defaultValue: 'surprised face',
|
||||
}),
|
||||
'party-popper': t('emojis.party-popper', { defaultValue: 'party popper' }),
|
||||
'folded-hands': t('emojis.folded-hands', { defaultValue: 'folded hands' }),
|
||||
}
|
||||
return emojiLabels[emoji] ?? emoji
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RiSettings3Line,
|
||||
RiSpeakerLine,
|
||||
RiVideoOnLine,
|
||||
RiEyeLine,
|
||||
} from '@remixicon/react'
|
||||
import { AccountTab } from './tabs/AccountTab'
|
||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||
@@ -22,6 +23,7 @@ import { useRef } from 'react'
|
||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import AccessibilityTab from './tabs/AccessibilityTab'
|
||||
|
||||
const tabsStyle = css({
|
||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||
@@ -112,6 +114,11 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
|
||||
</Tab>
|
||||
)}
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
|
||||
<RiEyeLine />
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
<div className={tabPanelContainerStyle}>
|
||||
@@ -125,6 +132,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
||||
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
|
||||
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
|
||||
</div>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Field, H } from '@/primitives'
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { accessibilityStore } from '@/stores/accessibility'
|
||||
|
||||
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
|
||||
|
||||
export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
|
||||
const { t } = useTranslation('settings')
|
||||
const snap = useSnapshot(accessibilityStore)
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('tabs.accessibility')}</H>
|
||||
<ul
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
<li>
|
||||
<Field
|
||||
type="switch"
|
||||
label={t('accessibility.announceReactions.label')}
|
||||
isSelected={snap.announceReactions}
|
||||
onChange={(value) => {
|
||||
accessibilityStore.announceReactions = value
|
||||
}}
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessibilityTab
|
||||
@@ -5,4 +5,5 @@ export enum SettingsDialogExtendedKey {
|
||||
GENERAL = 'general',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
TRANSCRIPTION = 'transcription',
|
||||
ACCESSIBILITY = 'accessibility',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Shortcut } from './types'
|
||||
|
||||
// Central list of current keyboard shortcuts.
|
||||
// Keep a single source of truth for display and, later, customization
|
||||
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
|
||||
|
||||
export type ShortcutId =
|
||||
| 'focus-toolbar'
|
||||
| 'toggle-microphone'
|
||||
| 'toggle-camera'
|
||||
| 'push-to-talk'
|
||||
| 'toggle-chat'
|
||||
| 'toggle-participants'
|
||||
| 'raise-hand'
|
||||
| 'recording'
|
||||
| 'reaction'
|
||||
| 'fullscreen'
|
||||
|
||||
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
||||
shortcutCatalog.find((item) => item.id === id)
|
||||
|
||||
export type ShortcutDescriptor = {
|
||||
id: ShortcutId
|
||||
category: ShortcutCategory
|
||||
shortcut?: Shortcut
|
||||
kind?: 'press' | 'longPress'
|
||||
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const shortcutCatalog: ShortcutDescriptor[] = [
|
||||
{
|
||||
id: 'focus-toolbar',
|
||||
category: 'navigation',
|
||||
shortcut: { key: 'F2' },
|
||||
},
|
||||
{
|
||||
id: 'toggle-microphone',
|
||||
category: 'media',
|
||||
shortcut: { key: 'd', ctrlKey: true },
|
||||
},
|
||||
{
|
||||
id: 'toggle-camera',
|
||||
category: 'media',
|
||||
shortcut: { key: 'e', ctrlKey: true },
|
||||
},
|
||||
{
|
||||
id: 'push-to-talk',
|
||||
category: 'media',
|
||||
kind: 'longPress',
|
||||
code: 'KeyV',
|
||||
},
|
||||
{
|
||||
id: 'reaction',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'E', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'fullscreen',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'F', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'recording',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'L', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'raise-hand',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'H', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'toggle-chat',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'M', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'toggle-participants',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
]
|
||||
@@ -1,4 +1,6 @@
|
||||
export type Shortcut = {
|
||||
key: string
|
||||
ctrlKey?: boolean
|
||||
shiftKey?: boolean
|
||||
altKey?: boolean
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ export const useKeyboardShortcuts = () => {
|
||||
// This approach handles basic shortcuts but isn't comprehensive.
|
||||
// Issues might occur. First draft.
|
||||
const onKeyDown = async (e: KeyboardEvent) => {
|
||||
const { key, metaKey, ctrlKey } = e
|
||||
const { key, metaKey, ctrlKey, shiftKey, altKey } = e
|
||||
if (!key) return
|
||||
const shortcutKey = formatShortcutKey({
|
||||
key,
|
||||
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||
shiftKey,
|
||||
altKey,
|
||||
})
|
||||
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||
if (!shortcut) return
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { useEffect } from 'react'
|
||||
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
|
||||
import { formatShortcutKey } from '@/features/shortcuts/utils'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
import { ShortcutId, getShortcutDescriptorById } from './catalog'
|
||||
|
||||
export type useRegisterKeyboardShortcutProps = {
|
||||
shortcut?: Shortcut
|
||||
id?: ShortcutId
|
||||
handler: () => Promise<void | boolean | undefined> | void
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export const useRegisterKeyboardShortcut = ({
|
||||
shortcut,
|
||||
id,
|
||||
handler,
|
||||
isDisabled = false,
|
||||
}: useRegisterKeyboardShortcutProps) => {
|
||||
useEffect(() => {
|
||||
if (!shortcut) return
|
||||
const formattedKey = formatShortcutKey(shortcut)
|
||||
if (!id) return
|
||||
const descriptor = getShortcutDescriptorById(id)
|
||||
if (!descriptor?.shortcut) return
|
||||
const formattedKey = formatShortcutKey(descriptor.shortcut)
|
||||
if (isDisabled) {
|
||||
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||
} else {
|
||||
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
|
||||
}
|
||||
}, [handler, shortcut, isDisabled])
|
||||
}, [handler, id, isDisabled])
|
||||
}
|
||||
|
||||
@@ -4,15 +4,27 @@ import { Shortcut } from '@/features/shortcuts/types'
|
||||
export const CTRL = 'ctrl'
|
||||
|
||||
export const formatShortcutKey = (shortcut: Shortcut) => {
|
||||
if (shortcut.ctrlKey) return `${CTRL}+${shortcut.key.toUpperCase()}`
|
||||
return shortcut.key.toUpperCase()
|
||||
const parts = []
|
||||
if (shortcut.ctrlKey) parts.push(CTRL)
|
||||
if (shortcut.altKey) parts.push('alt')
|
||||
if (shortcut.shiftKey) parts.push('shift')
|
||||
parts.push(shortcut.key.toUpperCase())
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
export const appendShortcutLabel = (label: string, shortcut: Shortcut) => {
|
||||
if (!shortcut.key) return
|
||||
let formattedKeyLabel = shortcut.key.toLowerCase()
|
||||
const parts: string[] = []
|
||||
if (shortcut.ctrlKey) {
|
||||
formattedKeyLabel = `${isMacintosh() ? '⌘' : 'Ctrl'}+${formattedKeyLabel}`
|
||||
parts.push(isMacintosh() ? '⌘' : 'Ctrl')
|
||||
}
|
||||
if (shortcut.altKey) {
|
||||
parts.push(isMacintosh() ? '⌥' : 'Alt')
|
||||
}
|
||||
if (shortcut.shiftKey) {
|
||||
parts.push('Shift')
|
||||
}
|
||||
parts.push(shortcut.key.toLowerCase())
|
||||
const formattedKeyLabel = parts.join('+')
|
||||
return `${label} (${formattedKeyLabel})`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSubtitles } from '../hooks/useSubtitles'
|
||||
import { css, cva } from '@/styled-system/css'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
@@ -13,101 +13,62 @@ export interface TranscriptionSegment {
|
||||
id: string
|
||||
text: string
|
||||
language: string
|
||||
startTime: number
|
||||
startTime?: number
|
||||
endTime: number
|
||||
final: boolean
|
||||
firstReceivedTime: number
|
||||
lastReceivedTime: number
|
||||
}
|
||||
|
||||
export interface TranscriptionSegmentWithParticipant extends TranscriptionSegment {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export interface TranscriptionRow {
|
||||
id: string
|
||||
participant: Participant
|
||||
segments: TranscriptionSegment[]
|
||||
startTime: number
|
||||
startTime?: number
|
||||
lastUpdateTime: number
|
||||
lastReceivedTime: number
|
||||
}
|
||||
|
||||
const useTranscriptionState = () => {
|
||||
const [transcriptionRows, setTranscriptionRows] = useState<
|
||||
TranscriptionRow[]
|
||||
const [transcriptionSegments, setTranscriptionSegments] = useState<
|
||||
TranscriptionSegmentWithParticipant[]
|
||||
>([])
|
||||
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
|
||||
useState<string | null>(null)
|
||||
|
||||
const updateTranscriptions = (
|
||||
const updateTranscriptionSegments = (
|
||||
segments: TranscriptionSegment[],
|
||||
participant?: Participant
|
||||
) => {
|
||||
console.log(participant, segments)
|
||||
|
||||
if (!participant || segments.length === 0) return
|
||||
|
||||
setTranscriptionRows((prevRows) => {
|
||||
const updatedRows = [...prevRows]
|
||||
const now = Date.now()
|
||||
if (segments.length > 1) {
|
||||
console.warn('Unexpected error more segments')
|
||||
return
|
||||
}
|
||||
|
||||
const shouldAppendToLastRow =
|
||||
lastActiveParticipantIdentity === participant.identity &&
|
||||
updatedRows.length > 0
|
||||
const segment = segments[0]
|
||||
|
||||
if (shouldAppendToLastRow) {
|
||||
const lastRowIndex = updatedRows.length - 1
|
||||
const lastRow = updatedRows[lastRowIndex]
|
||||
|
||||
const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id))
|
||||
const newSegments = segments.filter(
|
||||
(segment) => !existingSegmentIds.has(segment.id)
|
||||
)
|
||||
const updatedSegments = lastRow.segments.map((existing) => {
|
||||
const update = segments.find((s) => s.id === existing.id)
|
||||
return update && update.final ? update : existing
|
||||
})
|
||||
|
||||
updatedRows[lastRowIndex] = {
|
||||
...lastRow,
|
||||
segments: [...updatedSegments, ...newSegments],
|
||||
lastUpdateTime: now,
|
||||
}
|
||||
} else {
|
||||
const newRow: TranscriptionRow = {
|
||||
id: `${participant.identity}-${now}`,
|
||||
participant,
|
||||
segments: [...segments],
|
||||
startTime: Math.min(...segments.map((s) => s.startTime)),
|
||||
lastUpdateTime: now,
|
||||
}
|
||||
updatedRows.push(newRow)
|
||||
}
|
||||
|
||||
return updatedRows
|
||||
})
|
||||
|
||||
setLastActiveParticipantIdentity(participant.identity)
|
||||
}
|
||||
|
||||
const clearTranscriptions = () => {
|
||||
setTranscriptionRows([])
|
||||
setLastActiveParticipantIdentity(null)
|
||||
}
|
||||
|
||||
const updateParticipant = (_name: string, participant: Participant) => {
|
||||
setTranscriptionRows((prevRows) => {
|
||||
return prevRows.map((row) => {
|
||||
if (row.participant.identity === participant.identity) {
|
||||
return {
|
||||
...row,
|
||||
participant,
|
||||
}
|
||||
}
|
||||
return row
|
||||
})
|
||||
setTranscriptionSegments((prevSegments) => {
|
||||
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
|
||||
if (existingSegmentIds.has(segment.id)) return prevSegments
|
||||
return [
|
||||
...prevSegments,
|
||||
{
|
||||
participant: participant,
|
||||
...segment,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
transcriptionRows,
|
||||
updateTranscriptions,
|
||||
clearTranscriptions,
|
||||
updateParticipant,
|
||||
updateTranscriptionSegments,
|
||||
transcriptionSegments,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,24 +153,54 @@ const SubtitlesWrapper = styled(
|
||||
export const Subtitles = () => {
|
||||
const { areSubtitlesOpen } = useSubtitles()
|
||||
const room = useRoomContext()
|
||||
const { transcriptionRows, updateTranscriptions, updateParticipant } =
|
||||
|
||||
const { transcriptionSegments, updateTranscriptionSegments } =
|
||||
useTranscriptionState()
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions)
|
||||
room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
|
||||
return () => {
|
||||
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions)
|
||||
room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
|
||||
}
|
||||
}, [room, updateTranscriptions])
|
||||
}, [room, updateTranscriptionSegments])
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantNameChanged, updateParticipant)
|
||||
const transcriptionRows = useMemo(() => {
|
||||
if (transcriptionSegments.length === 0) return []
|
||||
|
||||
const rows: TranscriptionRow[] = []
|
||||
let currentRow: TranscriptionRow | null = null
|
||||
|
||||
for (const segment of transcriptionSegments) {
|
||||
const shouldStartNewRow =
|
||||
!currentRow ||
|
||||
currentRow.participant.identity !== segment.participant.identity
|
||||
|
||||
if (shouldStartNewRow) {
|
||||
currentRow = {
|
||||
id: `${segment.participant.identity}-${segment.firstReceivedTime}`,
|
||||
participant: segment.participant,
|
||||
segments: [segment],
|
||||
startTime: segment.startTime,
|
||||
lastUpdateTime: segment.lastReceivedTime,
|
||||
lastReceivedTime: segment.lastReceivedTime,
|
||||
}
|
||||
rows.push(currentRow)
|
||||
} else if (currentRow) {
|
||||
currentRow.segments.push(segment)
|
||||
currentRow.lastUpdateTime = Math.max(
|
||||
currentRow.lastUpdateTime,
|
||||
segment.lastReceivedTime
|
||||
)
|
||||
currentRow.lastReceivedTime = Math.max(
|
||||
currentRow.lastReceivedTime,
|
||||
segment.lastReceivedTime
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [room, updateParticipant])
|
||||
|
||||
return rows
|
||||
}, [transcriptionSegments])
|
||||
|
||||
return (
|
||||
<SubtitlesWrapper areOpen={areSubtitlesOpen}>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
announceToScreenReader,
|
||||
type Politeness,
|
||||
type ScreenReaderChannel,
|
||||
} from '@/stores/screenReaderAnnouncer'
|
||||
|
||||
export const useScreenReaderAnnounce = () => {
|
||||
return useCallback(
|
||||
(
|
||||
message: string,
|
||||
politeness: Politeness = 'polite',
|
||||
channel: ScreenReaderChannel = 'global'
|
||||
) => {
|
||||
announceToScreenReader(message, politeness, channel)
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { FeedbackBanner } from '@/components/FeedbackBanner'
|
||||
import { Menu } from '@/primitives/Menu'
|
||||
import { MenuList } from '@/primitives/MenuList'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
|
||||
@@ -90,6 +91,11 @@ export const Header = () => {
|
||||
const isTermsOfService = useMatchesRoute('termsOfService')
|
||||
const isRoom = useMatchesRoute('room')
|
||||
const { user, isLoggedIn, logout } = useUser()
|
||||
const userLabel = user?.full_name || user?.email
|
||||
const loggedInTooltip = t('loggedInUserTooltip')
|
||||
const loggedInAriaLabel = userLabel
|
||||
? `${loggedInTooltip} ${userLabel}`
|
||||
: loggedInTooltip
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -153,23 +159,24 @@ export const Header = () => {
|
||||
)}
|
||||
{!!user && (
|
||||
<Menu>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondaryText"
|
||||
tooltip={t('loggedInUserTooltip')}
|
||||
tooltipType="delayed"
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '350px',
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
<Button size="sm" variant="secondaryText">
|
||||
<VisualOnlyTooltip
|
||||
tooltip={loggedInTooltip}
|
||||
ariaLabel={loggedInAriaLabel}
|
||||
tooltipPosition="bottom"
|
||||
>
|
||||
{user?.full_name || user?.email}
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '350px',
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
>
|
||||
{user?.full_name || user?.email}
|
||||
</span>
|
||||
</VisualOnlyTooltip>
|
||||
</Button>
|
||||
<MenuList
|
||||
variant={'light'}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Header } from './Header'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { Footer } from '@/layout/Footer'
|
||||
import { ScreenReaderAnnouncer } from '@/primitives'
|
||||
|
||||
export type Layout = 'fullpage' | 'centered'
|
||||
|
||||
@@ -41,6 +42,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<ScreenReaderAnnouncer />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -44,18 +44,15 @@
|
||||
},
|
||||
"slide1": {
|
||||
"title": "Wechseln Sie zur Einfachheit. Testen Sie uns jetzt!",
|
||||
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere.",
|
||||
"imgAlt": "Illustration einer benutzerfreundlichen und barrierefreien Kollaborationsplattform"
|
||||
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Gruppenanrufe ohne Einschränkungen",
|
||||
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation – unabhängig von der Gruppengröße.",
|
||||
"imgAlt": "Bild eines virtuellen Meetings mit mehreren nahtlos zusammenarbeitenden Teilnehmenden"
|
||||
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation – unabhängig von der Gruppengröße."
|
||||
},
|
||||
"slide3": {
|
||||
"title": "Verwandeln Sie Ihre Meetings mit KI",
|
||||
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta – jetzt testen!",
|
||||
"imgAlt": "Illustration KI-gestützter Notizen in einem virtuellen Meeting"
|
||||
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta – jetzt testen!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"success": {
|
||||
"title": "Ihre Aufzeichnung ist bereit!",
|
||||
"body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.",
|
||||
"body": "Aufzeichnung des Treffens {{room}} vom {{created_at}}.",
|
||||
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
|
||||
"button": "Herunterladen",
|
||||
"warning": {
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"heading": "Ihr Meeting ist bereit",
|
||||
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
|
||||
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
|
||||
"closeDialog": "Dialogfenster schließen",
|
||||
"phone": {
|
||||
"call": "Rufen Sie an:",
|
||||
"pinCode": "Code:"
|
||||
@@ -153,9 +154,12 @@
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Bist du noch da?",
|
||||
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
|
||||
"settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.",
|
||||
"settingsPrefix": "Um diese Nachricht nicht mehr zu sehen, gehe zu",
|
||||
"settingsLink": "Einstellungen > Allgemein",
|
||||
"settingsSuffix": ".",
|
||||
"stayButton": "Gespräch fortsetzen",
|
||||
"leaveButton": "Jetzt verlassen"
|
||||
"leaveButton": "Jetzt verlassen",
|
||||
"countdownAnnouncement": "Das Gespräch endet in {{duration}}."
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Mikrofon",
|
||||
@@ -192,7 +196,9 @@
|
||||
"leave": "Verlassen",
|
||||
"participants": {
|
||||
"open": "Alle ausblenden",
|
||||
"closed": "Alle anzeigen"
|
||||
"closed": "Alle anzeigen",
|
||||
"count_one": "{{count}} Teilnehmer",
|
||||
"count_other": "{{count}} Teilnehmer"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Weitere Tools ausblenden",
|
||||
@@ -206,7 +212,18 @@
|
||||
"reactions": {
|
||||
"button": "Reaktion senden",
|
||||
"send": "Reaktion {{emoji}} senden",
|
||||
"you": "Sie"
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "Sie",
|
||||
"emojis": {
|
||||
"thumbs-up": "Daumen hoch",
|
||||
"thumbs-down": "Daumen runter",
|
||||
"clapping-hands": "Klatschende Hände",
|
||||
"red-heart": "rotes Herz",
|
||||
"face-with-tears-of-joy": "Gesicht mit Freudentränen",
|
||||
"face-with-open-mouth": "überraschter Gesichtsausdruck",
|
||||
"party-popper": "Party-Popper",
|
||||
"folded-hands": "gefaltete Hände"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -277,6 +294,7 @@
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Seitenleiste",
|
||||
"backToTools": "Zurück zu den Meeting-Tools",
|
||||
"heading": {
|
||||
"participants": "Teilnehmer",
|
||||
"effects": "Effekte",
|
||||
@@ -489,6 +507,7 @@
|
||||
"participants": {
|
||||
"subheading": "Im Raum",
|
||||
"you": "Du",
|
||||
"unknown": "Unbekannter Teilnehmer",
|
||||
"host": "Host",
|
||||
"contributors": "Mitwirkende",
|
||||
"collapsable": {
|
||||
@@ -536,6 +555,14 @@
|
||||
"ariaLabel": "Hefte {{name}} an"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
"pin": "Das Video von {{name}} ist angeheftet.",
|
||||
"unpin": "Das Video von {{name}} ist nicht mehr angeheftet.",
|
||||
"self": {
|
||||
"pin": "Dein Video ist angeheftet.",
|
||||
"unpin": "Dein Video ist nicht mehr angeheftet."
|
||||
}
|
||||
},
|
||||
"recordingStateToast": {
|
||||
"transcript": {
|
||||
"started": "Transkription läuft",
|
||||
|
||||
@@ -108,12 +108,18 @@
|
||||
"label": "Sprache"
|
||||
},
|
||||
"settingsButtonLabel": "Einstellungen",
|
||||
"accessibility": {
|
||||
"announceReactions": {
|
||||
"label": "Reaktionen vorlesen"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Profil",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "Allgemein",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"accessibility": "Barrierefreiheit",
|
||||
"transcription": "Transkription"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,115 +3,127 @@
|
||||
"articles": {
|
||||
"article1": {
|
||||
"title": "1. Geltungsbereich",
|
||||
"content": "Dieses Dokument definiert die Nutzungsbedingungen für Visio, den Videokonferenzdienst der Verwaltung."
|
||||
"content": "Dieses Dokument legt die Nutzungsbedingungen für Visio (nachfolgend „Visio“ oder „die Anwendung“) fest, eine Videokonferenzanwendung, die speziell für staatliche Verwaltungen und ihnen unterstellte Einrichtungen entwickelt wurde."
|
||||
},
|
||||
"article2": {
|
||||
"title": "2. Zweck der Plattform",
|
||||
"content": "Der Dienst fördert die Zusammenarbeit im Team und das Arbeiten aus der Ferne und erleichtert die Organisation von Besprechungen, Konferenzen oder Schulungen.",
|
||||
"purposes": "Visio wird von DINUM entwickelt und betrieben. Jede Nutzung des Dienstes muss mit diesen Nutzungsbedingungen übereinstimmen."
|
||||
"title": "2. Zweck der Anwendung",
|
||||
"content": "Visio fördert die Zusammenarbeit der Teams staatlicher Verwaltungen und ihnen unterstellter Einrichtungen, unterstützt die Arbeit auf Distanz und erleichtert die Organisation von Besprechungen, Konferenzen oder Schulungen.",
|
||||
"paragraphs": [
|
||||
"Visio wird von der Interministeriellen Direktion für Digitales (DINUM) entwickelt und betrieben.",
|
||||
"Die Anwendung wird von der DINUM staatlichen Verwaltungen und ihnen unterstellten Einrichtungen für deren Mitarbeitende, Partner und eingeladene Dienstleister zur Verfügung gestellt.",
|
||||
"Jede Nutzung von Visio muss diesen Nutzungsbedingungen sowie gegebenenfalls den Nutzungsvorgaben der zuständigen Verwaltung entsprechen."
|
||||
]
|
||||
},
|
||||
"article3": {
|
||||
"title": "3. Zugang zum Dienst",
|
||||
"definition": "Visio wird öffentlichen Bediensteten zur Verfügung gestellt, die über ProConnect verbunden sind, um einen Videokonferenzraum zu verwalten. Der verbundene öffentliche Bedienstete kann dann einen Link übermitteln, der den Zugang zum Raum unter seiner Verantwortung ermöglicht."
|
||||
"title": "3. Begriffsbestimmungen",
|
||||
"paragraphs": [
|
||||
"Visio ist das Produkt, dessen Nutzung in diesen Bedingungen beschrieben wird und das auf der folgenden Seite vorgestellt ist: https://visio.numerique.gouv.fr.",
|
||||
"Nutzerinnen und Nutzer von Visio sind alle Personen, die Visio zu Zwecken der Videokommunikation verwenden, unabhängig davon, ob sie Mitarbeitende, Partner oder Dienstleister der zuständigen Verwaltungen sind. Die zuständige Verwaltung ist die staatliche Verwaltung oder ihr unterstellte Einrichtung, die Visio ihren Mitarbeitenden, Partnern oder Dienstleistern zur Verfügung stellt.",
|
||||
"Raumorganisatorinnen und -organisatoren sind Nutzerinnen und Nutzer, die nach Anmeldung über ProConnect für einen Videokonferenzraum verantwortlich sind."
|
||||
]
|
||||
},
|
||||
"article4": {
|
||||
"title": "4. Nutzung",
|
||||
"content": "Die Nutzung des Dienstes ist kostenlos. Um einen Videokonferenzraum zu verwalten, ist eine Verbindung über ProConnect erforderlich."
|
||||
"title": "4. Zugang zur Anwendung",
|
||||
"definition": "Visio wird den Nutzerinnen und Nutzern von ihrer zuständigen Verwaltung zur Verfügung gestellt. Organisatorinnen und Organisatoren können anschließend unter eigener Verantwortung einen Zugangslink zum Raum an beliebige Personen weitergeben."
|
||||
},
|
||||
"article5": {
|
||||
"title": "5. Funktionen",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "5.1. Funktionen für öffentliche Bedienstete",
|
||||
"content": "Visio bietet öffentlichen Bediensteten die Möglichkeit, von einem Standardarbeitsplatz aus einen Videokonferenzraum zu erstellen, der mit ihrem ProConnect-Konto verknüpft ist.",
|
||||
"paragraph1": "Jeder Staatsbedienstete, dessen Tätigkeiten mit der Nutzung von Visio vereinbar sind oder der keinen spezifischen Vertraulichkeitsverpflichtungen oder dem Berufsgeheimnis unterliegt, kann eine Videokonferenz erstellen. Er kann dann den Besprechungslink mit anderen Teilnehmern teilen, unabhängig davon, ob sie Teil der Verwaltung sind oder nicht.",
|
||||
"paragraph2": "Zu diesem Zweck kann jeder Raumorganisator:",
|
||||
"title": "5.1. Funktionen für Organisatorinnen und Organisatoren",
|
||||
"content": "Visio ermöglicht Mitarbeitenden staatlicher Verwaltungen und ihnen unterstellter Einrichtungen, von einem Standardarbeitsplatz aus einen Videokonferenzraum zu erstellen, der mit ihrem ProConnect-Konto verknüpft ist.",
|
||||
"paragraph1": "Jede Mitarbeiterin und jeder Mitarbeiter des Staates oder einer ihm unterstellten Einrichtung, deren Tätigkeiten mit der Nutzung von Visio vereinbar sind oder die keinen besonderen Vertraulichkeits- oder Berufsgeheimnispflichten unterliegen, kann eine Videokonferenz erstellen und organisieren. Der Einladungslink kann anschließend mit anderen Personen, Nutzerinnen und Nutzern, innerhalb oder außerhalb der Verwaltung geteilt werden.",
|
||||
"paragraph2": "Zu diesem Zweck kann jede Raumorganisatorin bzw. jeder Raumorganisator:",
|
||||
"capabilities": [
|
||||
"Den Zugang zum Raum mit einem Warteraum schützen;",
|
||||
"Den Eintritt jedes Teilnehmers in den Raum autorisieren;",
|
||||
"Die Mikrofone und Kameras der Teilnehmer stummschalten;",
|
||||
"Einen Teilnehmer aus dem Raum entfernen."
|
||||
"den Zugang zum Raum durch einen Warteraum schützen;",
|
||||
"den Zutritt jeder Nutzerin und jedes Nutzers zum Raum zulassen;",
|
||||
"Mikrofone und Kameras der Nutzerinnen und Nutzer stummschalten;",
|
||||
"eine Nutzerin oder einen Nutzer aus dem Raum entfernen;",
|
||||
"das Transkriptionstool der Sitzung nach Einholung der Zustimmung der Nutzerinnen und Nutzer unter eigener Verantwortung verwenden."
|
||||
],
|
||||
"paragraph3": "Nur Bedienstete, deren Tätigkeitsbereich zur Nutzung von Visio berechtigt ist oder die <u>keinen spezifischen Vertraulichkeitsverpflichtungen oder dem Berufsgeheimnis unterliegen</u>, dürfen diesen Dienst und seine Funktionen nutzen oder kontrollieren."
|
||||
"paragraph3": "Nur Mitarbeitende, deren ProConnect-Konto zu einer zur Raumorganisation in Visio berechtigten Domäne gehört oder die keiner besonderen Vertraulichkeits- oder Berufsgeheimnispflicht unterliegen, dürfen die Anwendung und ihre Funktionen nutzen oder kontrollieren.",
|
||||
"paragraph4": "Das automatische Transkriptionstool kann von Organisatorinnen und Organisatoren über das Einstellungsmenü des Raums genutzt werden. Es liegt in ihrer Verantwortung, die Zustimmung der an der transkribierten Sitzung teilnehmenden Nutzerinnen und Nutzer sicherzustellen. Die Transkriptionssprache kann im selben Menü geändert werden. Am Ende der Sitzung erhält die Organisatorin bzw. der Organisator einen Link zum Zugriff auf die im von der DINUM betriebenen Tool „Docs“ gespeicherte Transkription."
|
||||
},
|
||||
"section2": {
|
||||
"title": "5.2. Funktionen für Nutzer",
|
||||
"content": "Visio ermöglicht es jedem Nutzer, ob öffentlicher Bediensteter oder nicht, an jedem Visio-Raum teilzunehmen, zu dem er von einem Staatsbediensteten eingeladen wurde, und alle Funktionen zu nutzen.",
|
||||
"paragraph": "Zu diesem Zweck kann jeder Teilnehmer:",
|
||||
"title": "5.2. Funktionen für Nutzerinnen und Nutzer",
|
||||
"content": "Visio ermöglicht es jeder Nutzerin und jedem Nutzer, unabhängig davon, ob sie oder er dem öffentlichen Dienst angehört oder nicht, an jedem Visio-Raum teilzunehmen, zu dem sie oder er von einer Organisatorin oder einem Organisator eingeladen wurde, und die Funktionen zu nutzen.",
|
||||
"paragraph": "Zu diesem Zweck kann jede Nutzerin und jeder Nutzer:",
|
||||
"capabilities": [
|
||||
"Seine angezeigte Identität beim Betreten des Raums festlegen;",
|
||||
"Über Audio und/oder Video aus der Ferne kommunizieren;",
|
||||
"Seinen Bildschirm teilen;",
|
||||
"Während der Sitzung öffentlich mit allen Nachrichten austauschen;",
|
||||
"Informationen über die Konferenz einsehen;",
|
||||
"Seine Kamera und/oder sein Mikrofon jederzeit ein- oder ausschalten;",
|
||||
"Die Privatsphäre seines Arbeitsplatzes schützen, indem er vor dem Betreten des Raums oder sogar während der Videokonferenz einen Hintergrund auswählt und aktiviert."
|
||||
"beim Betreten des Raums einen Anzeigenamen angeben;",
|
||||
"per Audio und/oder Video aus der Ferne kommunizieren;",
|
||||
"den eigenen Bildschirm teilen;",
|
||||
"während der Sitzung öffentliche Nachrichten mit allen Nutzerinnen und Nutzern des Raums austauschen;",
|
||||
"auf technische Informationen zum Raum und Barrierefreiheitsfunktionen zugreifen;",
|
||||
"Kamera und/oder Mikrofon jederzeit ein- oder ausschalten;",
|
||||
"die Privatsphäre der eigenen Umgebung bei aktivierter Kamera schützen, indem vor oder während der Videokonferenz ein Hintergrund ausgewählt und aktiviert wird."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"article6": {
|
||||
"title": "6. Verpflichtungen und Verantwortlichkeiten der Nutzer",
|
||||
"title": "6. Verpflichtungen und Verantwortlichkeiten der Nutzerinnen und Nutzer",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "6.1. Konforme Nutzung",
|
||||
"title": "6.1 Zulässige Nutzung",
|
||||
"paragraphs": [
|
||||
"Der Dienst wird öffentlichen Bediensteten über ProConnect zur Verfügung gestellt.",
|
||||
"Der Nutzer ist für die Daten oder Inhalte verantwortlich, die er im Nachrichtendienst des Videokonferenzraums eingibt. Er muss sicherstellen, dass er nur angemessene Nachrichten eingibt."
|
||||
"Visio wird bereitgestellt, um den beruflichen Austausch zwischen Mitarbeitenden staatlicher Verwaltungen und ihnen unterstellter Einrichtungen sowie externen Mitarbeitenden zu erleichtern.",
|
||||
"Nutzerinnen und Nutzer sind verantwortlich für die Bilder und Töne, die sie im Videokonferenzraum zugänglich machen. Diese Verantwortung gilt sowohl für das, was Kamera oder Mikrofon übertragen, als auch für das, was ihr Endgerät Dritten zugänglich machen kann. Daher ist die Anwendung in einem professionellen Umfeld zu nutzen, und es dürfen keine Informationen, die der beruflichen Verschwiegenheitspflicht unterliegen, an Dritte weitergegeben werden (z. B. durch Nutzung von Sichtschutzfiltern, Kopfhörern usw.).",
|
||||
"Nutzerinnen und Nutzer sind für die Daten oder Inhalte verantwortlich, die sie in den Nachrichtenbereich des Raums eingeben. Es dürfen nur angemessene Nachrichten eingegeben werden."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "6.2. Verbotene Nutzung",
|
||||
"title": "6.2 Unzulässige Nutzung",
|
||||
"paragraphs": [
|
||||
"Der Nutzer verpflichtet sich, keine Inhalte oder Informationen in den Nachrichtendienst des Visio-Raums einzugeben, die gegen geltende gesetzliche und regulatorische Bestimmungen verstoßen."
|
||||
"Nutzerinnen und Nutzer verpflichten sich, im Nachrichtenbereich von Visio oder über Bild oder Ton keine Inhalte oder Informationen zugänglich zu machen, die gegen geltende gesetzliche oder regulatorische Bestimmungen verstoßen."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. Verpflichtungen und Verantwortlichkeiten von DINUM",
|
||||
"content": "DINUM ist für die allgemeine Verwaltung des Dienstes verantwortlich. Sie stellt den Bediensteten alle erforderlichen Informationen zur Verfügung, um die Nutzung zu erleichtern. Sie bietet technische und funktionale Unterstützung, um den ordnungsgemäßen Betrieb des Dienstes sicherzustellen. Sie informiert die Nutzer auf jede zumutbare Weise über jede Schwierigkeit, die diesen ordnungsgemäßen Betrieb beeinträchtigen könnte.",
|
||||
"title": "7. Verpflichtungen und Verantwortlichkeiten der DINUM",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "7.1. Verfügbarkeitsniveau",
|
||||
"title": "7.1. Sicherheit der Anwendung",
|
||||
"paragraphs": [
|
||||
"Der Dienst ist 24/7 verfügbar, vorbehaltlich Wartungszeiten.",
|
||||
"DINUM verfolgt ein jährliches Verfügbarkeitsziel von 99,5 % (ohne geplante Ausfälle). Bei einem Vorfall oder einer Wartung strebt sie eine Wiederherstellungszeit von 72 Stunden außerhalb der Geschäftszeiten an.",
|
||||
"Im Rahmen der Wartung der Ausführungsumgebung des Dienstes behält sich DINUM das Recht vor, den Betrieb des Dienstes vorübergehend auszusetzen. Sie informiert die Nutzer mindestens 48 Stunden im Voraus. In dringenden Fällen kann diese Aussetzung ohne Vorankündigung erfolgen.",
|
||||
"Diese außergewöhnlichen Stillstände können beispielsweise für Maßnahmen zur Datenverwaltung im Back Office, Produktionsbereitstellungen oder Architekturänderungen erforderlich sein.",
|
||||
"Eine Nichtverfügbarkeit des Dienstes begründet keinerlei Anspruch auf Entschädigung."
|
||||
"DINUM verpflichtet sich zur Absicherung von Visio, insbesondere durch alle notwendigen Maßnahmen zur Gewährleistung der Sicherheit und Vertraulichkeit der Kommunikation.",
|
||||
"Die Anwendung ist gemäß den Bedingungen des geänderten Dekrets Nr. 2010-112 zugelassen."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2. Sicherheit des Dienstes",
|
||||
"title": "7.2. Zugang zur Anwendung",
|
||||
"paragraphs": [
|
||||
"DINUM ergreift alle erforderlichen Vorsichtsmaßnahmen, um die Sicherheit der Plattform und der im Rahmen des Dienstes eingesetzten Werkzeuge zu gewährleisten, insbesondere in Bezug auf den Zugang zum Dienst, die Verwaltung der Nutzerkonten und die Verarbeitung der erhobenen Daten."
|
||||
"DINUM verpflichtet sich, die notwendigen und angemessenen Mittel bereitzustellen, um einen kontinuierlichen Zugang zur Anwendung sicherzustellen.",
|
||||
"Die Anwendung ist 24 Stunden am Tag, 7 Tage die Woche verfügbar, außer während Wartungszeiten.",
|
||||
"DINUM strebt eine jährliche Verfügbarkeit der Anwendung von 99,5 % an, ausgenommen geplante Ausfälle. Im Falle eines Vorfalls oder einer Wartung wird eine Wiederherstellungszeit von 72 Stunden außerhalb der Geschäftszeiten angestrebt.",
|
||||
"Im Rahmen von Wartungsarbeiten an der Laufzeitumgebung der Anwendung behält sich DINUM das Recht vor, den Betrieb vorübergehend auszusetzen. Soweit möglich, werden die Nutzerinnen und Nutzer mindestens 48 Stunden im Voraus informiert. In Notfällen kann die Aussetzung ohne Vorankündigung erfolgen.",
|
||||
"Solche außergewöhnlichen Ausfälle können beispielsweise für Datenverwaltungsmaßnahmen, Produktivsetzungen oder Architekturänderungen erforderlich sein.",
|
||||
"Eine Nichtverfügbarkeit der Anwendung begründet keinen Anspruch auf irgendeine Form von Entschädigung."
|
||||
]
|
||||
},
|
||||
"section3": {
|
||||
"title": "7.3. Support-Management",
|
||||
"title": "7.3. Support",
|
||||
"paragraphs": [
|
||||
"DINUM leistet First-Level-Support für die Nutzer, ausschließlich zu den technischen Teilen des Dienstes.",
|
||||
"Dieser Support ist über unser Online-Kontaktformular erreichbar.",
|
||||
"Die angestrebte Antwortzeit auf Anfragen beträgt zwei Werktage ab Absenden; dieser Zeitraum kann je nach Komplexität und Anzahl der zu bearbeitenden Anfragen variieren."
|
||||
"DINUM bietet den Nutzerinnen und Nutzern Support der ersten Ebene, ausschließlich zu technischen Aspekten der Anwendung.",
|
||||
"Dieser Support ist über das Online-Kontaktformular erreichbar.",
|
||||
"Die angestrebte Antwortzeit beträgt zwei Arbeitstage ab Versand der Anfrage; dieser Zeitraum kann je nach Komplexität und Anzahl der Anfragen variieren."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Kontrolle der Dienstenutzung",
|
||||
"title": "7.4. Kontrolle der Nutzung der Anwendung",
|
||||
"paragraphs": [
|
||||
"DINUM behält sich das Recht vor, ein Agentenkonto zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger straf- und zivilrechtlicher Maßnahmen gegen den betreffenden Agenten.",
|
||||
"Die Sperrung oder Aufhebung eines oder mehrerer Konten begründet keinen Anspruch auf irgendeine Entschädigung."
|
||||
"DINUM behält sich das Recht vor, ein Nutzerkonto zu sperren oder zu löschen, wenn gegen diese Nutzungsbedingungen verstoßen wurde, unbeschadet möglicher straf- oder zivilrechtlicher Schritte gegen die betreffende Person. Informationen können an die zuständige Verwaltung übermittelt werden, die für die Bereitstellung der Anwendung verantwortlich ist.",
|
||||
"Die Sperrung oder Löschung eines oder mehrerer Konten begründet keinen Anspruch auf Entschädigung irgendeiner Art."
|
||||
]
|
||||
},
|
||||
"section5": {
|
||||
"title": "7.5. Open Source und Lizenzen",
|
||||
"content": "Der Quellcode des Dienstes ist frei und hier verfügbar: https://github.com/suitenumerique/meet. Die von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
|
||||
"content": "Der Quellcode des Dienstes ist offen und hier verfügbar: https://github.com/suitenumerique/meet. Von DINUM bereitgestellte Inhalte stehen unter der Open License 2.0, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die eigenen Lizenzen unterliegen können."
|
||||
}
|
||||
}
|
||||
},
|
||||
"article8": {
|
||||
"title": "8. Änderung der Nutzungsbedingungen",
|
||||
"content": "Die Bedingungen dieser Nutzungsbedingungen können jederzeit ohne Vorankündigung geändert oder ergänzt werden, abhängig von Änderungen am Dienst, Gesetzesänderungen oder aus anderen als notwendig erachteten Gründen.\nDiese Änderungen und Aktualisierungen sind für den Nutzer verbindlich, der daher regelmäßig diesen Abschnitt konsultieren muss, um die geltenden Bedingungen zu überprüfen."
|
||||
"title": "8. Änderungen der Nutzungsbedingungen",
|
||||
"content": "Die vorliegenden Nutzungsbedingungen können jederzeit und ohne Vorankündigung geändert oder ergänzt werden, insbesondere aufgrund von Änderungen am Dienst, gesetzlicher Entwicklungen oder aus anderen als notwendig erachteten Gründen.\nDiese Änderungen und Aktualisierungen sind für die Nutzerinnen und Nutzer verbindlich; sie sollten daher regelmäßig diesen Abschnitt konsultieren, um die geltenden Bedingungen zu überprüfen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"heading": "You don't have the permission to view this page"
|
||||
},
|
||||
"loading": "Loading…",
|
||||
"loggedInUserTooltip": "Logged in as…",
|
||||
"loggedInUserTooltip": "Logged in as ",
|
||||
"login": {
|
||||
"buttonLabel": "Login",
|
||||
"proconnectButtonLabel": "Login with ProConnect",
|
||||
|
||||
@@ -44,18 +44,15 @@
|
||||
},
|
||||
"slide1": {
|
||||
"title": "Make the switch to simplicity. Try us now!",
|
||||
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more.",
|
||||
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
|
||||
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Host group calls without limits",
|
||||
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size.",
|
||||
"imgAlt": "Image of a virtual meeting with multiple participants collaborating seamlessly"
|
||||
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size."
|
||||
},
|
||||
"slide3": {
|
||||
"title": "Transform your meetings with AI",
|
||||
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!",
|
||||
"imgAlt": "Illustration of AI-powered note-taking in a virtual meeting"
|
||||
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"success": {
|
||||
"title": "Your recording is ready!",
|
||||
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.",
|
||||
"body": "Recording of the meeting {{room}} from {{created_at}}.",
|
||||
"expiration": "Attention, this recording will expire after {{expiration_days}} day(s).",
|
||||
"button": "Download",
|
||||
"warning": {
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"heading": "Your meeting is ready",
|
||||
"description": "Share this link with people you want to invite to the meeting.",
|
||||
"permissions": "People with this link do not need your permission to join this meeting.",
|
||||
"closeDialog": "Close dialog",
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
"pinCode": "Code:"
|
||||
@@ -153,9 +154,12 @@
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Are you still there?",
|
||||
"body": "You are the only participant. This call will end in {{duration}}. Would you like to continue the call?",
|
||||
"settings": "To stop seeing this message, go to Settings > General.",
|
||||
"settingsPrefix": "To stop seeing this message, go to",
|
||||
"settingsLink": "Settings > General",
|
||||
"settingsSuffix": ".",
|
||||
"stayButton": "Continue the call",
|
||||
"leaveButton": "Leave now"
|
||||
"leaveButton": "Leave now",
|
||||
"countdownAnnouncement": "Call ends in {{duration}}."
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Microphone",
|
||||
@@ -192,7 +196,9 @@
|
||||
"leave": "Leave",
|
||||
"participants": {
|
||||
"open": "Hide everyone",
|
||||
"closed": "See everyone"
|
||||
"closed": "See everyone",
|
||||
"count_one": "{{count}} participant",
|
||||
"count_other": "{{count}} participants"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Hide more tools",
|
||||
@@ -206,7 +212,18 @@
|
||||
"reactions": {
|
||||
"button": "Send reaction",
|
||||
"send": "Send reaction {{emoji}}",
|
||||
"you": "you"
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "you",
|
||||
"emojis": {
|
||||
"thumbs-up": "thumbs up",
|
||||
"thumbs-down": "thumbs down",
|
||||
"clapping-hands": "clapping hands",
|
||||
"red-heart": "red heart",
|
||||
"face-with-tears-of-joy": "face with tears of joy",
|
||||
"face-with-open-mouth": "surprised face",
|
||||
"party-popper": "party popper",
|
||||
"folded-hands": "folded hands"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -277,6 +294,7 @@
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Sidepanel",
|
||||
"backToTools": "Back to meeting tools",
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Backgrounds and Effects",
|
||||
@@ -489,6 +507,7 @@
|
||||
"participants": {
|
||||
"subheading": "In room",
|
||||
"you": "You",
|
||||
"unknown": "Unknown participant",
|
||||
"host": "Host",
|
||||
"contributors": "Contributors",
|
||||
"collapsable": {
|
||||
@@ -536,6 +555,14 @@
|
||||
"ariaLabel": "Pin {{name}}"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
"pin": "The video of {{name}} is pinned.",
|
||||
"unpin": "The video of {{name}} is no longer pinned.",
|
||||
"self": {
|
||||
"pin": "Your video is pinned.",
|
||||
"unpin": "Your video is no longer pinned."
|
||||
}
|
||||
},
|
||||
"recordingStateToast": {
|
||||
"transcript": {
|
||||
"started": "Transcribing",
|
||||
|
||||
@@ -108,12 +108,18 @@
|
||||
"label": "Language"
|
||||
},
|
||||
"settingsButtonLabel": "Settings",
|
||||
"accessibility": {
|
||||
"announceReactions": {
|
||||
"label": "Announce reactions aloud"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Profile",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "General",
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibility",
|
||||
"transcription": "Transcription"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,115 +3,127 @@
|
||||
"articles": {
|
||||
"article1": {
|
||||
"title": "1. Scope",
|
||||
"content": "This document defines the terms of use for Visio, the videoconferencing service of the administration."
|
||||
"content": "This document defines the terms of use of Visio (hereinafter “Visio” or “the application”), a videoconferencing application specifically designed for State administrations and the bodies under their supervision."
|
||||
},
|
||||
"article2": {
|
||||
"title": "2. Purpose of the Platform",
|
||||
"content": "The service promotes team cooperation and remote work, and facilitates the organization of meetings, conferences, or training sessions.",
|
||||
"purposes": "Visio is developed and operated by DINUM. Any use of the service must comply with these terms of use."
|
||||
"title": "2. Purpose of the application",
|
||||
"content": "Visio helps foster cooperation among teams of State administrations and bodies under their supervision, supports remote work, and facilitates the organization of meetings, conferences, or training sessions.",
|
||||
"paragraphs": [
|
||||
"Visio is developed and operated by the Interministerial Directorate for Digital Affairs (DINUM).",
|
||||
"The application is made available by DINUM to State administrations and bodies under their supervision, for use by their staff members, partners, and invited service providers.",
|
||||
"Any use of Visio must comply with these terms of use and, where applicable, the usage policy of the relevant supervising administration."
|
||||
]
|
||||
},
|
||||
"article3": {
|
||||
"title": "3. Access to the Service",
|
||||
"definition": "Visio is made available to public agents connected through ProConnect for the purpose of administering a videoconference room. The connected public agent can then transmit a link allowing access to the room to any person, under their responsibility."
|
||||
"title": "3. Definitions",
|
||||
"paragraphs": [
|
||||
"Visio is the product whose use is described in these terms and presented on the following page: https://visio.numerique.gouv.fr.",
|
||||
"Users of Visio are any persons using Visio for videoconferencing communication purposes, whether they are staff members, partners, or service providers of the relevant administrations. The supervising administration is the State administration or body under its supervision that makes Visio available to its staff members, partners, or service providers.",
|
||||
"Room organizers are users who are responsible for a videoconference room after logging in via ProConnect."
|
||||
]
|
||||
},
|
||||
"article4": {
|
||||
"title": "4. Usage",
|
||||
"content": "The use of the service is free of charge. To administer a videoconference room, it is necessary to connect through ProConnect."
|
||||
"title": "4. Access to the application",
|
||||
"definition": "Visio is made available to users by their supervising administration. Organizers may then share a link granting access to the room with any person, under their responsibility."
|
||||
},
|
||||
"article5": {
|
||||
"title": "5. Features",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "5.1. Features Available to Public Agents",
|
||||
"content": "Visio offers public agents the ability to create a videoconference room from a standard workstation, linked to their ProConnect account.",
|
||||
"paragraph1": "Any State agent whose activities are compatible with the use of Visio or who is not subject to specific confidentiality obligations or professional secrecy, can create a videoconference. They can then share the meeting link with other participants, whether they are part of the administration or not.",
|
||||
"paragraph2": "To this end, each room organizer can:",
|
||||
"title": "5.1. Features available to organizers",
|
||||
"content": "Visio allows staff of State administrations and bodies under their supervision to create a videoconference room from a standard workstation, linked to their ProConnect account.",
|
||||
"paragraph1": "Any State staff member or staff member of a body under State supervision, whose activities are compatible with the use of Visio or who is not subject to specific confidentiality or professional secrecy obligations, may create and organize a videoconference. They may then share the meeting link with other persons, users, whether or not they belong to the administration.",
|
||||
"paragraph2": "For this purpose, each room organizer may:",
|
||||
"capabilities": [
|
||||
"Protect access to the room with a waiting room;",
|
||||
"Authorize the entry of each participant into the room;",
|
||||
"Mute the microphones and cameras of participants;",
|
||||
"Remove a participant from the room."
|
||||
"Authorize each user’s entry into the room;",
|
||||
"Mute users’ microphones and cameras;",
|
||||
"Remove a user from the room;",
|
||||
"Use the meeting transcription tool after obtaining the consent of users, under their responsibility."
|
||||
],
|
||||
"paragraph3": "Only agents whose field of activity is authorized to use Visio or who <u>are not subject to specific confidentiality obligations or professional secrecy</u>, may use or have control of this service and its features."
|
||||
"paragraph3": "Only staff members whose ProConnect account belongs to a domain authorized to organize rooms on Visio or who are not subject to specific confidentiality or professional secrecy obligations may use or have control over the application and its features.",
|
||||
"paragraph4": "The automatic transcription tool can be used by organizers from the room settings menu. Organizers are responsible for ensuring the consent of users participating in the transcribed meeting. The transcription language can be changed from the same menu. At the end of the meeting, a link is sent to the organizer to access the transcription stored in the tool called Docs operated by DINUM."
|
||||
},
|
||||
"section2": {
|
||||
"title": "5.2. Features Available to Users",
|
||||
"content": "Visio allows any user, whether a public agent or not, to participate in any Visio room to which they are invited by a State agent and to benefit from all its features.",
|
||||
"paragraph": "To this end, each participant can:",
|
||||
"title": "5.2. Features available to users",
|
||||
"content": "Visio allows any user, whether a public official or not, to participate in any Visio room to which they are invited by an organizer and to benefit from its features.",
|
||||
"paragraph": "For this purpose, each user may:",
|
||||
"capabilities": [
|
||||
"Set their displayed identity when entering the room;",
|
||||
"Provide a display name when entering the room;",
|
||||
"Communicate remotely via audio and/or video;",
|
||||
"Share their screen;",
|
||||
"Exchange messages during the session publicly with everyone;",
|
||||
"View information about the conference;",
|
||||
"Enable or disable their camera and/or microphone at any time;",
|
||||
"Protect the privacy of their workstation by selecting and activating a background before entering the room or even during the videoconference."
|
||||
"Exchange messages during the session publicly with all users in the room;",
|
||||
"Access technical information about the room and accessibility features;",
|
||||
"Turn their camera and/or microphone on or off at any time;",
|
||||
"Protect the privacy of their surroundings when the camera is on by selecting and activating a background before entering the room or during the videoconference."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"article6": {
|
||||
"title": "6. User Commitments and Responsibilities",
|
||||
"title": "6. Commitments and responsibilities of users",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "6.1 Compliant Uses",
|
||||
"title": "6.1 Permitted uses",
|
||||
"paragraphs": [
|
||||
"The service is made available to public agents through ProConnect.",
|
||||
"The user is responsible for the data or content they enter in the videoconference room's messaging service. They must ensure they only enter appropriate messages."
|
||||
"Visio is provided to facilitate professional exchanges between staff of State administrations and bodies under their supervision and external collaborators.",
|
||||
"Users are responsible for the images and sounds they make accessible in the videoconference room. This responsibility applies both to what their camera or microphone may broadcast and to what their device may make accessible to third parties. To this end, users must use the application in a professional environment and must not disclose any information covered by professional confidentiality obligations to third parties (for example: use of privacy filters, headphones, etc.).",
|
||||
"Users are responsible for the data or content they enter into the room’s messaging service. They must ensure that only appropriate messages are entered."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "6.2 Prohibited Uses",
|
||||
"title": "6.2 Prohibited uses",
|
||||
"paragraphs": [
|
||||
"The user agrees not to enter content or information in the Visio room's messaging service that is contrary to current legal and regulatory provisions."
|
||||
"Users undertake not to enter into the Visio room messaging service, or make accessible via image or sound, any content or information that violates applicable laws and regulations."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. DINUM Commitments and Responsibilities",
|
||||
"content": "DINUM is responsible for the overall administration of the service. It provides agents with all necessary information to facilitate its use. It offers technical and functional assistance to ensure the proper operation of the service. It informs users by any reasonable means of any difficulty that may affect this proper operation.",
|
||||
"title": "7. Commitments and responsibilities of DINUM",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "7.1. Service Availability",
|
||||
"title": "7.1. Application security",
|
||||
"paragraphs": [
|
||||
"The service is available 24/7, except during maintenance windows.",
|
||||
"DINUM targets an annual service availability of 99.5%, excluding planned outages. In case of an incident or maintenance, it aims for a recovery time of 72 hours outside business hours.",
|
||||
"As part of maintaining the service runtime environment, DINUM reserves the right to temporarily suspend the operation of the service. It informs users at least 48 hours in advance. In emergencies, this suspension may occur without notice.",
|
||||
"These exceptional stoppages may be necessary, for example, for back-office data management operations, production deployments, or architecture changes.",
|
||||
"Service unavailability does not give rise to any compensation of any kind."
|
||||
"DINUM undertakes to secure Visio, in particular by taking all necessary measures to ensure the security and confidentiality of exchanges.",
|
||||
"The application is certified under the conditions provided for by amended Decree No. 2010-112."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2. Service Security",
|
||||
"title": "7.2. Access to the application",
|
||||
"paragraphs": [
|
||||
"DINUM takes all necessary precautions to preserve the security of the platform and the tools used within the service, particularly regarding access to the service, management of user accounts, and processing of collected data."
|
||||
"DINUM undertakes to provide the necessary and reasonable means to ensure continuous access to the application.",
|
||||
"The application is available 24/7, except during maintenance downtime.",
|
||||
"DINUM aims for an annual application availability rate of 99.5%, excluding planned downtime. In the event of an incident or maintenance, it aims for a restoration time of 72 hours outside business hours.",
|
||||
"As part of maintenance of the application’s runtime environment, DINUM reserves the right to temporarily suspend the operation of the application. Whenever possible, users will be informed at least 48 hours in advance. In emergencies, such suspension may occur without prior notice.",
|
||||
"These exceptional outages may be required, for example, for data management operations, production releases, or architectural changes.",
|
||||
"Application unavailability does not entitle users to any form of compensation."
|
||||
]
|
||||
},
|
||||
"section3": {
|
||||
"title": "7.3. Support Management",
|
||||
"title": "7.3. Support management",
|
||||
"paragraphs": [
|
||||
"DINUM provides first-level support to users, exclusively on the technical parts of the service.",
|
||||
"This support can be reached via our online contact form.",
|
||||
"The target response time to requests is two business days from submission; this timeframe may vary depending on the complexity and number of requests to be processed."
|
||||
"DINUM provides first-level support to users, exclusively on the technical aspects of the application.",
|
||||
"This support can be reached via the online contact form.",
|
||||
"The target response time to requests is two business days from the sending of the email; this timeframe may vary depending on the complexity and number of requests to be processed."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Monitoring Service Use",
|
||||
"title": "7.4. Monitoring application use",
|
||||
"paragraphs": [
|
||||
"DINUM reserves the right to suspend or delete an agent account that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the agent concerned.",
|
||||
"The suspension or revocation of one or more accounts does not entitle the user to any compensation of any kind."
|
||||
"DINUM reserves the right to suspend or delete a user account that has failed to comply with these terms of use, without prejudice to any criminal or civil liability actions that may be brought against the person concerned. Information may be transmitted to the user’s supervising administration, which is responsible for making the application available.",
|
||||
"The suspension or revocation of one or more accounts does not give rise to any compensation of any kind."
|
||||
]
|
||||
},
|
||||
"section5": {
|
||||
"title": "7.5. Open Source and Licenses",
|
||||
"content": "The service source code is free and available here: https://github.com/suitenumerique/meet. The content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
|
||||
"content": "The source code of the service is open and available here: https://github.com/suitenumerique/meet. Content provided by DINUM is licensed under the Open License 2.0, with the exception of logos and iconographic or photographic representations, which may be governed by their own licenses."
|
||||
}
|
||||
}
|
||||
},
|
||||
"article8": {
|
||||
"title": "8. Evolution of Terms of Use",
|
||||
"content": "The terms of these terms of use may be modified or supplemented at any time, without notice, depending on changes made to the service, changes in legislation, or for any other reason deemed necessary.\nThese modifications and updates are binding on the user who must, consequently, regularly refer to this section to verify the terms in force."
|
||||
"title": "8. Changes to the terms of use",
|
||||
"content": "The terms of these conditions of use may be modified or supplemented at any time, without notice, depending on changes made to the service, developments in legislation, or for any other reason deemed necessary.\nThese changes and updates are binding on the user, who must therefore regularly refer to this section to verify the current terms."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"heading": "Accès interdit"
|
||||
},
|
||||
"loading": "Chargement…",
|
||||
"loggedInUserTooltip": "Connecté en tant que…",
|
||||
"loggedInUserTooltip": "Connecté en tant que ",
|
||||
"login": {
|
||||
"buttonLabel": "Se connecter",
|
||||
"proconnectButtonLabel": "S'identifier avec ProConnect",
|
||||
|
||||
@@ -44,18 +44,15 @@
|
||||
},
|
||||
"slide1": {
|
||||
"title": "Passez à la simplicité. Essayez-nous dès maintenant !",
|
||||
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore.",
|
||||
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
|
||||
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Organisez des appels de groupe sans limite",
|
||||
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre.",
|
||||
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
|
||||
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre."
|
||||
},
|
||||
"slide3": {
|
||||
"title": "Transformez vos réunions avec l'IA",
|
||||
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !",
|
||||
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
|
||||
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"success": {
|
||||
"title": "Votre enregistrement est prêt !",
|
||||
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.",
|
||||
"body": "Enregistrement de la réunion {{room}} du {{created_at}}.",
|
||||
"expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).",
|
||||
"button": "Télécharger",
|
||||
"warning": {
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"heading": "Votre réunion est prête",
|
||||
"description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.",
|
||||
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
|
||||
"closeDialog": "Fermer la fenêtre modale",
|
||||
"phone": {
|
||||
"call": "Appelez le :",
|
||||
"pinCode": "Code :"
|
||||
@@ -153,9 +154,12 @@
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Êtes-vous toujours là ?",
|
||||
"body": "Vous êtes le seul participant. Cet appel va donc se terminer dans {{duration}}. Voulez-vous poursuivre l'appel ?",
|
||||
"settings": "Pour ne plus voir ce message, accèder à Paramètres > Général.",
|
||||
"settingsPrefix": "Pour ne plus voir ce message, allez dans",
|
||||
"settingsLink": "Paramètres > Général",
|
||||
"settingsSuffix": ".",
|
||||
"stayButton": "Poursuivre l'appel",
|
||||
"leaveButton": "Quitter maintenant"
|
||||
"leaveButton": "Quitter maintenant",
|
||||
"countdownAnnouncement": "L'appel se termine dans {{duration}}."
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Microphone",
|
||||
@@ -192,7 +196,9 @@
|
||||
"leave": "Quitter",
|
||||
"participants": {
|
||||
"open": "Masquer les participants",
|
||||
"closed": "Afficher les participants"
|
||||
"closed": "Afficher les participants",
|
||||
"count_one": "{{count}} participant",
|
||||
"count_other": "{{count}} participants"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Masquer les outils de réunion",
|
||||
@@ -206,7 +212,18 @@
|
||||
"reactions": {
|
||||
"button": "Envoyer une réaction",
|
||||
"send": "Envoyer la réaction {{emoji}}",
|
||||
"you": "vous"
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "vous",
|
||||
"emojis": {
|
||||
"thumbs-up": "pouce levé",
|
||||
"thumbs-down": "pouce baissé",
|
||||
"clapping-hands": "applaudissements",
|
||||
"red-heart": "cœur",
|
||||
"face-with-tears-of-joy": "visage qui rit",
|
||||
"face-with-open-mouth": "visage étonné",
|
||||
"party-popper": "confettis",
|
||||
"folded-hands": "mains jointes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -277,6 +294,7 @@
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Panneau latéral",
|
||||
"backToTools": "Retour aux outils de réunion",
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Arrière-plans et effets",
|
||||
@@ -489,6 +507,7 @@
|
||||
"participants": {
|
||||
"subheading": "Dans la réunion",
|
||||
"you": "Vous",
|
||||
"unknown": "Participant inconnu",
|
||||
"contributors": "Contributeurs",
|
||||
"host": "Organisateur de la réunion",
|
||||
"collapsable": {
|
||||
@@ -536,6 +555,14 @@
|
||||
"ariaLabel": "Épingler {{name}}"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
"pin": "La vidéo de {{name}} est épinglée.",
|
||||
"unpin": "La vidéo de {{name}} n’est plus épinglée.",
|
||||
"self": {
|
||||
"pin": "Votre vidéo est épinglée.",
|
||||
"unpin": "Votre vidéo n’est plus épinglée."
|
||||
}
|
||||
},
|
||||
"recordingStateToast": {
|
||||
"transcript": {
|
||||
"started": "Transcription en cours",
|
||||
|
||||
@@ -108,12 +108,18 @@
|
||||
"label": "Langue de l'application"
|
||||
},
|
||||
"settingsButtonLabel": "Paramètres",
|
||||
"accessibility": {
|
||||
"announceReactions": {
|
||||
"label": "Vocaliser les réactions"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Profil",
|
||||
"audio": "Audio",
|
||||
"video": "Vidéo",
|
||||
"general": "Général",
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibilité",
|
||||
"transcription": "Transcription"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,61 @@
|
||||
{
|
||||
"title": "Modalités d'utilisation de Visio",
|
||||
"title": "Modalités d’utilisation de Visio",
|
||||
"articles": {
|
||||
"article1": {
|
||||
"title": "1. Champ d'application",
|
||||
"content": "Le présent document définit les modalités d'utilisation de Visio, le service de visioconférence de l'administration."
|
||||
"title": "1. Champ d’application",
|
||||
"content": "Le présent document définit les modalités d’utilisation de Visio (ci-après « Visio » ou « l’application »), une application de visioconférence spécialement conçue pour les administrations de l’Etat et les organismes sous sa tutelle."
|
||||
},
|
||||
"article2": {
|
||||
"title": "2. Objet de la plateforme",
|
||||
"content": "Le service permet de favoriser la coopération des équipes et le travail à distance et de faciliter l'organisation de réunions, conférences ou formations.",
|
||||
"purposes": "Visio est développé et opéré par la DINUM. Toute utilisation du service doit respecter les présentes modalités d'utilisation."
|
||||
"title": "2. Objet de l’application",
|
||||
"content": "Visio permet de favoriser la coopération des équipes des administrations de l’Etat et des organismes sous sa tutelle et le travail à distance et de faciliter l’organisation de réunions, conférences ou formations.",
|
||||
"paragraphs": [
|
||||
"Visio est développée et opérée par la direction interministérielle du numérique (DINUM).",
|
||||
"L’application est mise à disposition par la DINUM aux administrations de l’Etat et aux organismes sous sa tutelle, à destination de leurs agents, partenaires et prestataires invités.",
|
||||
"Toute utilisation de Visio doit respecter les présentes modalités d’utilisation et, le cas échéant, la doctrine d’emploi de l’administration de rattachement."
|
||||
]
|
||||
},
|
||||
"article3": {
|
||||
"title": "3. Accès au service",
|
||||
"definition": "Visio est mis à disposition des agents publics connectés par ProConnect aux fins d'administrer un salon de visioconférence. L'agent public connecté peut ensuite transmettre un lien permettant d'accéder au salon à toute personne, sous sa responsabilité."
|
||||
"title": "3. Définitions",
|
||||
"paragraphs": [
|
||||
"Visio est le produit dont l’utilisation est décrite dans les présentes modalités et présenté sur la page suivante : https://visio.numerique.gouv.fr.",
|
||||
"Les utilisateurs et utilisatrices de Visio sont toute personne utilisant Visio à des fins de communication en visioconférence, qu’elles soient des agents, des partenaires ou des prestataires des administrations de rattachement. L’administration de rattachement est l’administration de l’Etat et des organismes sous sa tutelle qui met à disposition Visio à ses agents, partenaires ou prestataires.",
|
||||
"Les organisateurs et organisatrices de salon sont des utilisateurs et utilisatrices qui sont responsables d’un salon de visioconférence après s’être connecté par ProConnect."
|
||||
]
|
||||
},
|
||||
"article4": {
|
||||
"title": "4. Utilisation",
|
||||
"content": "L'utilisation du service est libre et gratuite, pour administrer un salon de visioconférence, il est nécessaire de se connecter par ProConnect."
|
||||
"title": "4. Accès à l’application",
|
||||
"definition": "Visio est mis à disposition des utilisateurs et utilisatrices par leur administration de rattachement. Les organisateurs et organisatrices peuvent ensuite transmettre un lien permettant d’accéder au salon à toute personne, sous leur responsabilité."
|
||||
},
|
||||
"article5": {
|
||||
"title": "5. Fonctionnalités",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "5.1. Fonctionnalités ouvertes aux agents publics",
|
||||
"content": "Visio offre aux agents publics la possibilité de créer un salon de visioconférence depuis un poste de travail standard, lié à son compte ProConnect.",
|
||||
"paragraph1": "Tout agent de l'État, dont les activités sont compatibles avec l'utilisation de Visio ou qui n'est pas soumis à des obligations particulières de confidentialité ou de secret professionnel, peut créer une visioconférence. Il peut ensuite partager le lien de la réunion avec d'autres participants, qu'ils fassent partie de l'administration ou non.",
|
||||
"paragraph2": "A cet effet, chaque organisateur de salon peut :",
|
||||
"title": "5.1. Fonctionnalités ouvertes aux organisateurs et organisatrices",
|
||||
"content": "Visio offre aux agents des administrations de l’Etat et des organismes sous sa tutelle la possibilité de créer un salon de visioconférence depuis un poste de travail standard, lié à leur compte ProConnect.",
|
||||
"paragraph1": "Tout agent de l’État ou d’un organisme sous sa tutelle, dont les activités sont compatibles avec l’utilisation de Visio ou qui n’est pas soumis à des obligations particulières de confidentialité ou de secret professionnel, peut créer et organiser une visioconférence. Il ou elle peut ensuite partager le lien de la réunion avec d’autres personnes, utilisateurs et utilisatrices, qu’elles fassent partie de l’administration ou non.",
|
||||
"paragraph2": "A cet effet, chaque organisateur ou organisatrice de salon peut :",
|
||||
"capabilities": [
|
||||
"Protéger l'accès au salon par une salle d'attente ;",
|
||||
"Autoriser l'entrée de chaque participant dans le salon ;",
|
||||
"Couper les micros et caméras des participants ;",
|
||||
"Exclure un participant du salon."
|
||||
"Protéger l’accès au salon par une salle d’attente ;",
|
||||
"Autoriser l’entrée de chaque utilisateur et utilisatrice dans le salon ;",
|
||||
"Couper les micros et caméras des utilisateurs et utilisatrices ;",
|
||||
"Exclure un utilisateur ou utilisatrice du salon ;",
|
||||
"Utiliser l’outil de transcription de la réunion après avoir obtenu l’aval des utilisateurs et des utilisatrices, sous sa responsabilité."
|
||||
],
|
||||
"paragraph3": "Seuls les agents, dont le domaine d'activité est autorisé à utiliser Visio ou qui <u>ne sont pas soumis à une obligation de confidentialité particulière ou de secret professionnel</u>, peuvent utiliser ou avoir la maîtrise de ce service et de ses fonctionnalités."
|
||||
"paragraph3": "Seuls les agents, dont le compte ProConnect relève d’un domaine autorisé à organiser des salons sur Visio ou qui <u>ne sont pas soumis à une obligation de confidentialité particulière ou de secret professionnel</u>, peuvent utiliser ou avoir la maîtrise de l’application et de ses fonctionnalités.",
|
||||
"paragraph4": "L’outil de transcription automatique peut être utilisé par les organisateurs et organisatrices depuis le menu des paramètres du salon. Il appartient aux organisateurs et organisatrices de s’assurer de l’aval des utilisateurs et utilisatrices participants à la réunion transcrite. La langue de transcription peut être modifiée depuis le même menu. À la fin de la réunion, un lien est transmis à l’organisateur ou l’organisatrice pour accéder à la transcription conservée sur l’outil intitulé Docs opéré par la DINUM."
|
||||
},
|
||||
"section2": {
|
||||
"title": "5.2. Fonctionnalités ouvertes aux utilisateurs et utilisatrices",
|
||||
"content": "Visio permet à tout utilisateur et tout utilisatrice, agent public ou non de participer à tout salon de Visio auquel il est convié par un agent de l'Etat et de profiter de toutes ses fonctionnalités.",
|
||||
"paragraph": "A cet effet, chaque participant et participante peut :",
|
||||
"content": "Visio permet à tout utilisateur et tout utilisatrice, agent public ou non de participer à tout salon de Visio auquel il est convié par un organisateur ou une organisatrice et de profiter des fonctionnalités.",
|
||||
"paragraph": "A cet effet, chaque utilisateur et utilisatrice peut :",
|
||||
"capabilities": [
|
||||
"décliner son identité affichée en entrant dans le salon ;",
|
||||
"fournir une identité affichée en entrant dans le salon ;",
|
||||
"échanger à distance en audio et/ou en vidéo ;",
|
||||
"partager son écran ;",
|
||||
"échanger des messages durant le salon de manière publique avec tous ;",
|
||||
"voir des informations sur la conférence ;",
|
||||
"échanger des messages durant le salon de manière publique avec tous les utilisateurs et utilisatrices de celui-ci ;",
|
||||
"accéder à des informations techniques sur le salon et des fonctionnalités d’accessibilité ;",
|
||||
"activer ou désactiver sa caméra et/ou son micro à tout instant ;",
|
||||
"protéger l'intimité de son poste en sélectionnant puis en activant un fond d'écran avant d'entrer dans le salon ou même durant la visioconférence."
|
||||
"protéger l’intimité de son environnement dans le cas d’une activation de la caméra en sélectionnant puis en activant un fond d’écran avant d’entrer dans le salon ou même durant la visioconférence."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -56,62 +66,64 @@
|
||||
"section1": {
|
||||
"title": "6.1 Usages conformes",
|
||||
"paragraphs": [
|
||||
"Le service est mis à disposition pour les agents publics par ProConnect.",
|
||||
"L'utilisateur ou l'utilisatrice est responsable des données ou contenus qu'il ou elle saisit dans le service de messagerie du salon de visioconférence. Il ou elle veille à ne saisir que des messages appropriés."
|
||||
"Visio est mis à disposition pour faciliter les échanges professionnels entre les agents des administrations de l’Etat et ses organismes sous tutelle et les collaborateurs extérieurs à l’administration.",
|
||||
"L’utilisateur ou l’utilisatrice est responsable des images et sons qu’il ou elle rend accessible dans le salon de visioconférence. Cette responsabilité vaut autant pour ce que la caméra ou le micro peut diffuser que pour ce que son terminal peut rendre accessible à des tiers. A cette fin, l’utilisateur ou l’utilisatrice veille à utiliser l’application dans un environnement professionnel et à ne diffuser aucune information qui serait couverte par l’obligation de discrétion professionnelle à des tiers (par exemple : utilisation de filtre de confidentialité, d’écouteurs, etc.).",
|
||||
"L’utilisateur ou l’utilisatrice est responsable des données ou contenus qu’il ou elle saisit dans le service de messagerie du salon de visioconférence. Il ou elle veille à ne saisir que des messages appropriés."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "6.2 Usages interdits",
|
||||
"paragraphs": [
|
||||
"L'utilisateur ou l'utilisatrice s'engage à ne pas saisir dans le service de messagerie du salon de Visio des contenus ou informations contraires aux dispositions légales et réglementaires en vigueur."
|
||||
"L’utilisateur ou l’utilisatrice s’engage à ne pas saisir dans le service de messagerie du salon de Visio ni rendre accessible par image ou son des contenus ou informations contraires aux dispositions légales et réglementaires en vigueur."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. Engagements et responsabilités de la DINUM",
|
||||
"content": "La DINUM est responsable de l’administration générale du service. Elle transmet aux agents toutes les informations nécessaires pour faciliter son utilisation. Elle propose une assistance technique et fonctionnelle en vue d’assurer le bon fonctionnement du service. Elle informe par tout moyen raisonnable les utilisateurs de toute difficulté de nature à affecter ce bon fonctionnement.",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "7.1. Niveau de disponibilité",
|
||||
"title": "7.1. Sécurité de l’application",
|
||||
"paragraphs": [
|
||||
"La plage d’ouverture du Service est 24h/24 7j/7, hors période d’indisponibilité pour maintenance.",
|
||||
"La DINUM poursuit un objectif de disponibilité annuelle du service de 99,5%, hors indisponibilités planifiées. En cas d’incident ou de maintenance, elle vise un délai de rétablissement de 72h en heures non ouvrables.",
|
||||
"Dans le cadre d’une maintenance de l’environnement d’exécution du service, La DINUM se réserve le droit de suspendre temporairement le fonctionnement du service. Elle tient informée les utilisateurs au minimum 48h à l’avance. En cas d’urgence, cette suspension peut intervenir sans préavis.",
|
||||
"Ces arrêts exceptionnels peuvent être rendus nécessaires par exemple pour des opérations de gestion des données en back office, des opérations de mise en production ou des changements d’architecture.",
|
||||
"L’indisponibilité du service n’ouvre droit à aucune compensation de quelque nature que ce soit."
|
||||
"La DINUM s’engage à la sécurisation de Visio, notamment en prenant toutes les mesures nécessaires permettant de garantir la sécurité et la confidentialité des échanges.",
|
||||
"L’application est homologuée dans les conditions prévues par le décret n° 2010-112 modifiée."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2. Sécurité du service",
|
||||
"title": "7.2. Accès à l’application",
|
||||
"paragraphs": [
|
||||
"La DINUM prend toutes les précautions utiles pour préserver la sécurité de la plateforme et des outils mis en œuvre dans le cadre du service, notamment s’agissant de l’accès au service, de la gestion des comptes utilisateurs et du traitement des données collectées."
|
||||
"La DINUM s’engage à fournir les moyens nécesaires et raisonnables pour assurer un accès continu à l’application.",
|
||||
"La plage d’ouverture de l’application est 24h/24 7j/7, hors période d’indisponibilité pour maintenance.",
|
||||
"La DINUM poursuit un objectif de disponibilité annuelle de l’application de 99,5%, hors indisponibilités planifiées. En cas d’incident ou de maintenance, elle vise un délai de rétablissement de 72h en heures non ouvrables.",
|
||||
"Dans le cadre d’une maintenance de l’environnement d’exécution de l’application, la DINUM se réserve le droit de suspendre temporairement le fonctionnement de l’application. Dans la mesure du possible, elle tient informée les utilisateurs au minimum 48h à l’avance. En cas d’urgence, cette suspension peut intervenir sans préavis.",
|
||||
"Ces arrêts exceptionnels peuvent être rendus nécessaires par exemple pour des opérations de gestion des données, de mise en production ou des changements d’architecture.",
|
||||
"L’indisponibilité de l’application n’ouvre droit à aucune compensation de quelque nature que ce soit."
|
||||
]
|
||||
},
|
||||
"section3": {
|
||||
"title": "7.3. Gestion du support",
|
||||
"paragraphs": [
|
||||
"La DINUM assure le support de premier niveau auprès des utilisateurs, exclusivement sur les parties techniques du service.",
|
||||
"Ce support est joignable sur notre formulaire de contact en ligne.",
|
||||
"La DINUM assure le support de premier niveau auprès des utilisateurs et utilisatrices, exclusivement sur les parties techniques de l’application.",
|
||||
"Ce support est joignable par le formulaire de contact en ligne.",
|
||||
"L’objectif de réponse aux sollicitations est de deux jours ouvrés à compter de l’envoi du courriel, ce délai peut varier selon la complexité et le nombre de demandes à traiter."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Contrôle de l’utilisation du service",
|
||||
"title": "7.4. Contrôle de l’utilisation de l’application",
|
||||
"paragraphs": [
|
||||
"La DINUM se réserve le droit de suspendre ou supprimer un compte agent qui aurait méconnu les présentes modalités d’utilisation, sans préjudice des éventuelles actions en responsabilité pénale et civile qui pourraient être engagées à l’encontre de l’agent concerné.",
|
||||
"La DINUM se réserve le droit de suspendre ou supprimer un compte d’utilisateur ou d’utilisatrice qui aurait méconnu les présentes modalités d’utilisation, sans préjudice des éventuelles actions en responsabilité pénale et civile qui pourraient être engagées à l’encontre la personne concernée. Les éléments pourraient être transmis à l’administration de rattachement de l’utilisatrice ou de l’utilisateur, celle-ci étant responsable de la mise à disposition de l’application.",
|
||||
"La suspension ou la révocation d’un ou plusieurs comptes ne donne lieu à aucune compensation d’aucune sorte."
|
||||
]
|
||||
},
|
||||
"section5": {
|
||||
"title": "7.5. Open Source et Licences",
|
||||
"content": "Le code source du service est libre et disponible ici : https://github.com/suitenumerique/meet. Les contenus proposés par la DINUM sont sous Licence Ouverte, à l’exception des logos et des représentations iconographiques et photographiques pouvant être régis par leurs licences propres."
|
||||
"content": "Le code source du service est libre et disponible ici : https://github.com/suitenumerique/meet. Les contenus proposés par la DINUM sont sous Licence Ouverte 2.0, à l’exception des logos et des représentations iconographiques et photographiques pouvant être régis par leurs licences propres."
|
||||
}
|
||||
}
|
||||
},
|
||||
"article8": {
|
||||
"title": "8. Évolution des modalités d'utilisation",
|
||||
"content": "Les termes des présentes modalités d'utilisation peuvent être modifiés ou complétés à tout moment, sans préavis, en fonction des modifications apportées au service, de l'évolution de la législation ou pour tout autre motif jugé nécessaire.\nCes modifications et mises à jour s'imposent à l'utilisateur ou l'utilisatrice qui doit, en conséquence, se référer régulièrement à cette rubrique pour vérifier les modalités en vigueur."
|
||||
"title": "8. Évolution des modalités d’utilisation",
|
||||
"content": "Les termes des présentes modalités d’utilisation peuvent être modifiés ou complétés à tout moment, sans préavis, en fonction des modifications apportées au service, de l’évolution de la législation ou pour tout autre motif jugé nécessaire.\nCes modifications et mises à jour s’imposent à l’utilisateur ou l’utilisatrice qui doit, en conséquence, se référer régulièrement à cette rubrique pour vérifier les modalités en vigueur."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"heading": "U hebt geen toestemming om deze pagina te bekijken"
|
||||
},
|
||||
"loading": "Laden ...",
|
||||
"loggedInUserTooltip": "Ingelogd als ...",
|
||||
"loggedInUserTooltip": "Ingelogd als ",
|
||||
"login": {
|
||||
"buttonLabel": "Log in",
|
||||
"proconnectButtonLabel": "Log in met Proconnect",
|
||||
|
||||
@@ -44,18 +44,15 @@
|
||||
},
|
||||
"slide1": {
|
||||
"title": "Stap over op eenvoud. Probeer ons nu!",
|
||||
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer.",
|
||||
"imgAlt": "Illustratie van een gebruiksvriendelijk en toegankelijk samenwerkingsplatform"
|
||||
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Houdt groepsgesprekken zonder limieten",
|
||||
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte.",
|
||||
"imgAlt": "Afbeelding van een virtuele ontmoeting met meerdere deelnemers die naadloos samenwerken"
|
||||
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte."
|
||||
},
|
||||
"slide3": {
|
||||
"title": "Transformeer uw vergaderingen met AI",
|
||||
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!",
|
||||
"imgAlt": "Illustratie van AI-aangedreven notitie in een virtuele vergadering"
|
||||
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"success": {
|
||||
"title": "Je opname is klaar!",
|
||||
"body": "Opname van de vergadering <b>{{room}}</b> op {{created_at}}.",
|
||||
"body": "Opname van de vergadering {{room}} op {{created_at}}.",
|
||||
"expiration": "Let op, deze opname verloopt na {{expiration_days}} dag(en).",
|
||||
"button": "Downloaden",
|
||||
"warning": {
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"heading": "Uw vergadering is klaar",
|
||||
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
|
||||
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
|
||||
"closeDialog": "Sluit het dialoogvenster",
|
||||
"phone": {
|
||||
"call": "Bel:",
|
||||
"pinCode": "Code:"
|
||||
@@ -153,9 +154,12 @@
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Ben je er nog?",
|
||||
"body": "Je bent de enige deelnemer. Dit gesprek wordt over {{duration}} beëindigd. Wil je het gesprek voortzetten?",
|
||||
"settings": "Om dit bericht niet meer te zien, ga naar Instellingen > Algemeen.",
|
||||
"settingsPrefix": "Om dit bericht niet meer te zien, ga naar",
|
||||
"settingsLink": "Instellingen > Algemeen",
|
||||
"settingsSuffix": ".",
|
||||
"stayButton": "Gesprek voortzetten",
|
||||
"leaveButton": "Nu verlaten"
|
||||
"leaveButton": "Nu verlaten",
|
||||
"countdownAnnouncement": "Het gesprek eindigt over {{duration}}."
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Microfoon",
|
||||
@@ -192,7 +196,9 @@
|
||||
"leave": "Vertrekken",
|
||||
"participants": {
|
||||
"open": "Verberg iedereen",
|
||||
"closed": "Toon iedereen"
|
||||
"closed": "Toon iedereen",
|
||||
"count_one": "{{count}} deelnemer",
|
||||
"count_other": "{{count}} deelnemers"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Meer tools verbergen",
|
||||
@@ -206,7 +212,18 @@
|
||||
"reactions": {
|
||||
"button": "Stuur reactie",
|
||||
"send": "Stuur reactie {{emoji}}",
|
||||
"you": "U"
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "U",
|
||||
"emojis": {
|
||||
"thumbs-up": "duim omhoog",
|
||||
"thumbs-down": "duim omlaag",
|
||||
"clapping-hands": "applaudisserende handen",
|
||||
"red-heart": "rood hart",
|
||||
"face-with-tears-of-joy": "gezicht met tranen van vreugde",
|
||||
"face-with-open-mouth": "verrast gezicht",
|
||||
"party-popper": "feestknaller",
|
||||
"folded-hands": "gevouwen handen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -277,6 +294,7 @@
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Zijbalk",
|
||||
"backToTools": "Terug naar vergadertools",
|
||||
"heading": {
|
||||
"participants": "Deelnemers",
|
||||
"effects": "Effecten",
|
||||
@@ -489,6 +507,7 @@
|
||||
"participants": {
|
||||
"subheading": "In de ruimte",
|
||||
"you": "U",
|
||||
"unknown": "Onbekende deelnemer",
|
||||
"host": "Host",
|
||||
"contributors": "Deelnemers",
|
||||
"collapsable": {
|
||||
@@ -536,6 +555,14 @@
|
||||
"ariaLabel": "Maak {{name}} vast"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
"pin": "De video van {{name}} is vastgezet.",
|
||||
"unpin": "De video van {{name}} is niet meer vastgezet.",
|
||||
"self": {
|
||||
"pin": "Uw video is vastgezet.",
|
||||
"unpin": "Uw video is niet meer vastgezet."
|
||||
}
|
||||
},
|
||||
"recordingStateToast": {
|
||||
"transcript": {
|
||||
"started": "Transcriptie bezig",
|
||||
|
||||
@@ -1,117 +1,129 @@
|
||||
{
|
||||
"title": "Terms of Use for Visio",
|
||||
"title": "Gebruiksvoorwaarden voor Visio",
|
||||
"articles": {
|
||||
"article1": {
|
||||
"title": "1. Scope",
|
||||
"content": "This document defines the terms of use for Visio, the videoconferencing service of the administration."
|
||||
"title": "1. Toepassingsgebied",
|
||||
"content": "Dit document bepaalt de gebruiksvoorwaarden van Visio (hierna “Visio” of “de applicatie”), een videoconferentieapplicatie die speciaal is ontworpen voor overheidsadministraties en instanties die onder hun toezicht staan."
|
||||
},
|
||||
"article2": {
|
||||
"title": "2. Purpose of the Platform",
|
||||
"content": "The service promotes team cooperation and remote work, and facilitates the organization of meetings, conferences, or training sessions.",
|
||||
"purposes": "Visio is developed and operated by DINUM. Any use of the service must comply with these terms of use."
|
||||
"title": "2. Doel van de applicatie",
|
||||
"content": "Visio bevordert de samenwerking tussen teams van overheidsadministraties en onder hun toezicht staande instanties, ondersteunt werken op afstand en vergemakkelijkt de organisatie van vergaderingen, conferenties of opleidingen.",
|
||||
"paragraphs": [
|
||||
"Visio wordt ontwikkeld en beheerd door de Interministeriële Directie voor Digitale Zaken (DINUM).",
|
||||
"De applicatie wordt door DINUM ter beschikking gesteld aan overheidsadministraties en instanties onder hun toezicht, ten behoeve van hun medewerkers, partners en uitgenodigde dienstverleners.",
|
||||
"Elk gebruik van Visio moet in overeenstemming zijn met deze gebruiksvoorwaarden en, indien van toepassing, met het gebruiksbeleid van de bevoegde administratie."
|
||||
]
|
||||
},
|
||||
"article3": {
|
||||
"title": "3. Access to the Service",
|
||||
"definition": "Visio is made available to public agents connected through ProConnect for the purpose of administering a videoconference room. The connected public agent can then transmit a link allowing access to the room to any person, under their responsibility."
|
||||
"title": "3. Definities",
|
||||
"paragraphs": [
|
||||
"Visio is het product waarvan het gebruik in deze voorwaarden wordt beschreven en dat wordt gepresenteerd op de volgende pagina: https://visio.numerique.gouv.fr.",
|
||||
"Gebruikers van Visio zijn alle personen die Visio gebruiken voor videocommunicatiedoeleinden, ongeacht of zij medewerkers, partners of dienstverleners zijn van de betrokken administraties. De bevoegde administratie is de overheidsadministratie of de onder haar toezicht staande instantie die Visio ter beschikking stelt aan haar medewerkers, partners of dienstverleners.",
|
||||
"Ruimteorganisatoren zijn gebruikers die verantwoordelijk zijn voor een videoconferentieruimte na inloggen via ProConnect."
|
||||
]
|
||||
},
|
||||
"article4": {
|
||||
"title": "4. Usage",
|
||||
"content": "The use of the service is free of charge. To administer a videoconference room, it is necessary to connect through ProConnect."
|
||||
"title": "4. Toegang tot de applicatie",
|
||||
"definition": "Visio wordt aan gebruikers ter beschikking gesteld door hun bevoegde administratie. Organisatoren kunnen vervolgens, onder hun verantwoordelijkheid, een toegangslink tot de ruimte delen met elke persoon."
|
||||
},
|
||||
"article5": {
|
||||
"title": "5. Features",
|
||||
"title": "5. Functionaliteiten",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "5.1. Features Available to Public Agents",
|
||||
"content": "Visio offers public agents the ability to create a videoconference room from a standard workstation, linked to their ProConnect account.",
|
||||
"paragraph1": "Any State agent whose activities are compatible with the use of Visio or who is not subject to specific confidentiality obligations or professional secrecy, can create a videoconference. They can then share the meeting link with other participants, whether they are part of the administration or not.",
|
||||
"paragraph2": "To this end, each room organizer can:",
|
||||
"title": "5.1. Functionaliteiten voor organisatoren",
|
||||
"content": "Visio biedt medewerkers van overheidsadministraties en onder hun toezicht staande instanties de mogelijkheid om vanaf een standaardwerkstation een videoconferentieruimte aan te maken, gekoppeld aan hun ProConnect-account.",
|
||||
"paragraph1": "Elke medewerker van de Staat of van een onder zijn toezicht staande instantie, van wie de activiteiten verenigbaar zijn met het gebruik van Visio of die niet onder specifieke vertrouwelijkheids- of beroepsgeheimverplichtingen valt, kan een videoconferentie aanmaken en organiseren. De vergaderlink kan vervolgens worden gedeeld met andere personen, gebruikers, binnen of buiten de administratie.",
|
||||
"paragraph2": "Daartoe kan elke ruimteorganisator:",
|
||||
"capabilities": [
|
||||
"Protect access to the room with a waiting room;",
|
||||
"Authorize the entry of each participant into the room;",
|
||||
"Mute the microphones and cameras of participants;",
|
||||
"Remove a participant from the room."
|
||||
"de toegang tot de ruimte beveiligen met een wachtruimte;",
|
||||
"de toegang van elke gebruiker tot de ruimte toestaan;",
|
||||
"microfoons en camera’s van gebruikers dempen;",
|
||||
"een gebruiker uit de ruimte verwijderen;",
|
||||
"de transcriptietool van de vergadering gebruiken na toestemming van de gebruikers, onder eigen verantwoordelijkheid."
|
||||
],
|
||||
"paragraph3": "Only agents whose field of activity is authorized to use Visio or who <u>are not subject to specific confidentiality obligations or professional secrecy</u>, may use or have control of this service and its features."
|
||||
"paragraph3": "Alleen medewerkers van wie het ProConnect-account behoort tot een domein dat gemachtigd is om ruimtes op Visio te organiseren, of die niet onder specifieke vertrouwelijkheids- of beroepsgeheimverplichtingen vallen, mogen de applicatie en haar functionaliteiten gebruiken of beheren.",
|
||||
"paragraph4": "De automatische transcriptietool kan door organisatoren worden gebruikt via het instellingenmenu van de ruimte. Het is de verantwoordelijkheid van de organisatoren om de toestemming te verkrijgen van de gebruikers die deelnemen aan de getranscribeerde vergadering. De transcriptietaal kan in hetzelfde menu worden gewijzigd. Aan het einde van de vergadering ontvangt de organisator een link om de transcriptie te raadplegen die wordt bewaard in de door DINUM beheerde tool “Docs”."
|
||||
},
|
||||
"section2": {
|
||||
"title": "5.2. Features Available to Users",
|
||||
"content": "Visio allows any user, whether a public agent or not, to participate in any Visio room to which they are invited by a State agent and to benefit from all its features.",
|
||||
"paragraph": "To this end, each participant can:",
|
||||
"title": "5.2. Functionaliteiten voor gebruikers",
|
||||
"content": "Visio stelt elke gebruiker, al dan niet een ambtenaar, in staat deel te nemen aan elke Visio-ruimte waarvoor hij of zij door een organisator is uitgenodigd en gebruik te maken van de functionaliteiten.",
|
||||
"paragraph": "Daartoe kan elke gebruiker:",
|
||||
"capabilities": [
|
||||
"Set their displayed identity when entering the room;",
|
||||
"Communicate remotely via audio and/or video;",
|
||||
"Share their screen;",
|
||||
"Exchange messages during the session publicly with everyone;",
|
||||
"View information about the conference;",
|
||||
"Enable or disable their camera and/or microphone at any time;",
|
||||
"Protect the privacy of their workstation by selecting and activating a background before entering the room or even during the videoconference."
|
||||
"een weergavenaam opgeven bij het betreden van de ruimte;",
|
||||
"op afstand communiceren via audio en/of video;",
|
||||
"zijn of haar scherm delen;",
|
||||
"tijdens de sessie openbare berichten uitwisselen met alle gebruikers van de ruimte;",
|
||||
"toegang krijgen tot technische informatie over de ruimte en toegankelijkheidsfuncties;",
|
||||
"de camera en/of microfoon op elk moment in- of uitschakelen;",
|
||||
"de privacy van de eigen omgeving beschermen bij gebruik van de camera door vóór of tijdens de videoconferentie een achtergrond te selecteren en te activeren."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"article6": {
|
||||
"title": "6. User Commitments and Responsibilities",
|
||||
"title": "6. Verplichtingen en verantwoordelijkheden van gebruikers",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "6.1 Compliant Uses",
|
||||
"title": "6.1 Toegestaan gebruik",
|
||||
"paragraphs": [
|
||||
"The service is made available to public agents through ProConnect.",
|
||||
"The user is responsible for the data or content they enter in the videoconference room's messaging service. They must ensure they only enter appropriate messages."
|
||||
"Visio wordt ter beschikking gesteld om professionele uitwisselingen tussen medewerkers van overheidsadministraties en onder hun toezicht staande instanties en externe medewerkers te vergemakkelijken.",
|
||||
"Gebruikers zijn verantwoordelijk voor de beelden en geluiden die zij in de videoconferentieruimte toegankelijk maken. Deze verantwoordelijkheid geldt zowel voor wat hun camera of microfoon kan uitzenden als voor wat hun apparaat toegankelijk kan maken voor derden. Daarom moeten gebruikers de applicatie in een professionele omgeving gebruiken en geen informatie die onder de beroepsgeheimverplichting valt met derden delen (bijvoorbeeld door gebruik van privacyfilters, hoofdtelefoons, enz.).",
|
||||
"Gebruikers zijn verantwoordelijk voor de gegevens of inhoud die zij invoeren in de berichtenservice van de ruimte. Zij moeten ervoor zorgen dat alleen gepaste berichten worden ingevoerd."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "6.2 Prohibited Uses",
|
||||
"title": "6.2 Verboden gebruik",
|
||||
"paragraphs": [
|
||||
"The user agrees not to enter content or information in the Visio room's messaging service that is contrary to current legal and regulatory provisions."
|
||||
"Gebruikers verbinden zich ertoe geen inhoud of informatie in de berichtenservice van Visio in te voeren, noch via beeld of geluid toegankelijk te maken, die in strijd is met de geldende wettelijke en reglementaire bepalingen."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. DINUM Commitments and Responsibilities",
|
||||
"content": "De DINUM is verantwoordelijk voor de algemene administratie van de dienst. Zij verstrekt de medewerkers alle nodige informatie om het gebruik te vergemakkelijken. Zij biedt technische en functionele ondersteuning om de goede werking van de dienst te verzekeren. Zij informeert de gebruikers met elk redelijk middel over moeilijkheden die deze goede werking kunnen beïnvloeden.",
|
||||
"title": "7. Verplichtingen en verantwoordelijkheden van DINUM",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "7.1. Beschikbaarheidsniveau",
|
||||
"title": "7.1. Beveiliging van de applicatie",
|
||||
"paragraphs": [
|
||||
"De dienstverlening is 24u/24 en 7d/7 beschikbaar, behalve tijdens onderhoudsonderbrekingen.",
|
||||
"DINUM streeft naar een jaarlijkse beschikbaarheid van 99,5% van de dienst, buiten geplande onbeschikbaarheden. Bij een incident of onderhoud mikt zij op een hersteltermijn van 72 uur buiten kantooruren.",
|
||||
"In het kader van onderhoud van de uitvoeringsomgeving van de dienst behoudt DINUM zich het recht voor de werking tijdelijk op te schorten. Zij informeert de gebruikers minstens 48 uur vooraf. In noodgevallen kan deze opschorting zonder voorafgaande kennisgeving plaatsvinden.",
|
||||
"Deze uitzonderlijke stilstanden kunnen bijvoorbeeld nodig zijn voor backoffice-gegevensbeheer, inproductiestellingen of architectuurwijzigingen.",
|
||||
"Onbeschikbaarheid van de dienst geeft geen recht op enige vorm van compensatie."
|
||||
"DINUM verbindt zich ertoe Visio te beveiligen, met name door alle noodzakelijke maatregelen te nemen om de veiligheid en vertrouwelijkheid van de uitwisselingen te waarborgen.",
|
||||
"De applicatie is gecertificeerd volgens de voorwaarden van het gewijzigde decreet nr. 2010-112."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2. Beveiliging van de dienst",
|
||||
"title": "7.2. Toegang tot de applicatie",
|
||||
"paragraphs": [
|
||||
"DINUM neemt alle nodige voorzorgsmaatregelen om de veiligheid van het platform en de in het kader van de dienst gebruikte hulpmiddelen te bewaren, met name wat betreft de toegang tot de dienst, het beheer van gebruikersaccounts en de verwerking van verzamelde gegevens."
|
||||
"DINUM verbindt zich ertoe de noodzakelijke en redelijke middelen te verschaffen om een continue toegang tot de applicatie te waarborgen.",
|
||||
"De applicatie is 24 uur per dag, 7 dagen per week beschikbaar, behalve tijdens onderhoudsperiodes.",
|
||||
"DINUM streeft naar een jaarlijkse beschikbaarheid van de applicatie van 99,5%, met uitsluiting van geplande onderbrekingen. In geval van een incident of onderhoud wordt gestreefd naar een hersteltijd van 72 uur buiten kantooruren.",
|
||||
"In het kader van onderhoud aan de uitvoeringsomgeving van de applicatie behoudt DINUM zich het recht voor de werking van de applicatie tijdelijk op te schorten. Indien mogelijk worden gebruikers ten minste 48 uur van tevoren geïnformeerd. In noodgevallen kan deze opschorting zonder voorafgaande kennisgeving plaatsvinden.",
|
||||
"Deze uitzonderlijke onderbrekingen kunnen bijvoorbeeld noodzakelijk zijn voor gegevensbeheer, productiereleases of architectuurwijzigingen.",
|
||||
"Onbeschikbaarheid van de applicatie geeft geen recht op enige vorm van compensatie."
|
||||
]
|
||||
},
|
||||
"section3": {
|
||||
"title": "7.3. Supportbeheer",
|
||||
"title": "7.3. Ondersteuning",
|
||||
"paragraphs": [
|
||||
"DINUM verzorgt eerstelijns support voor de gebruikers, uitsluitend over de technische onderdelen van de dienst.",
|
||||
"Deze support is bereikbaar via ons online contactformulier.",
|
||||
"De streefantwoordtermijn op verzoeken is twee werkdagen vanaf de verzending; deze termijn kan variëren naargelang de complexiteit en het aantal te behandelen aanvragen."
|
||||
"DINUM biedt eerstelijnsondersteuning aan gebruikers, uitsluitend met betrekking tot de technische aspecten van de applicatie.",
|
||||
"Deze ondersteuning is bereikbaar via het online contactformulier.",
|
||||
"De beoogde reactietijd bedraagt twee werkdagen vanaf verzending van het verzoek; deze termijn kan variëren afhankelijk van de complexiteit en het aantal te behandelen verzoeken."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Controle van het gebruik van de dienst",
|
||||
"title": "7.4. Controle op het gebruik van de applicatie",
|
||||
"paragraphs": [
|
||||
"DINUM behoudt zich het recht voor om een agentaccount te schorsen of te verwijderen dat deze gebruiksvoorwaarden heeft geschonden, onverminderd eventuele strafrechtelijke en civielrechtelijke stappen tegen de betrokken agent.",
|
||||
"De schorsing of intrekking van één of meer accounts geeft geen recht op enige vorm van compensatie."
|
||||
"DINUM behoudt zich het recht voor een gebruikersaccount te schorsen of te verwijderen dat deze gebruiksvoorwaarden niet naleeft, onverminderd eventuele strafrechtelijke of civielrechtelijke procedures tegen de betrokken persoon. Informatie kan worden doorgegeven aan de bevoegde administratie, die verantwoordelijk is voor het ter beschikking stellen van de applicatie.",
|
||||
"De schorsing of intrekking van één of meerdere accounts geeft geen recht op enige vorm van compensatie."
|
||||
]
|
||||
},
|
||||
"section5": {
|
||||
"title": "7.5. Open Source en licenties",
|
||||
"content": "De broncode van de dienst is vrij beschikbaar op: https://github.com/suitenumerique/meet. De inhoud die door DINUM wordt aangeboden valt onder een Open License, met uitzondering van logo's en iconografische en fotografische voorstellingen die onder hun eigen licenties kunnen vallen."
|
||||
"content": "De broncode van de dienst is open en hier beschikbaar: https://github.com/suitenumerique/meet. Inhoud die door DINUM wordt aangeboden valt onder de Open License 2.0, met uitzondering van logo’s en iconografische of fotografische weergaven, die onder hun eigen licenties kunnen vallen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"article8": {
|
||||
"title": "8. Evolution of Terms of Use",
|
||||
"content": "The terms of these terms of use may be modified or supplemented at any time, without notice, depending on changes made to the service, changes in legislation, or for any other reason deemed necessary.\nThese modifications and updates are binding on the user who must, consequently, regularly refer to this section to verify the terms in force."
|
||||
"title": "8. Wijzigingen van de gebruiksvoorwaarden",
|
||||
"content": "De voorwaarden van deze gebruiksvoorwaarden kunnen te allen tijde zonder voorafgaande kennisgeving worden gewijzigd of aangevuld, afhankelijk van wijzigingen aan de dienst, ontwikkelingen in de wetgeving of om andere noodzakelijk geachte redenen.\nDeze wijzigingen en updates zijn bindend voor de gebruiker, die daarom regelmatig deze rubriek moet raadplegen om de geldende voorwaarden te controleren."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,9 @@ export const Field = <T extends object>({
|
||||
const LabelAndDescription = (
|
||||
<>
|
||||
<StyledLabel {...props.labelProps}>{label}</StyledLabel>
|
||||
<FieldDescription slot="description">{description}</FieldDescription>
|
||||
{description ? (
|
||||
<FieldDescription slot="description">{description}</FieldDescription>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
const RACFieldErrors = (
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
screenReaderAnnouncerStore,
|
||||
type ScreenReaderChannel,
|
||||
} from '@/stores/screenReaderAnnouncer'
|
||||
|
||||
export const ScreenReaderAnnouncer = ({
|
||||
channel = 'global',
|
||||
}: {
|
||||
channel?: ScreenReaderChannel
|
||||
}) => {
|
||||
const { announcements } = useSnapshot(screenReaderAnnouncerStore)
|
||||
const announcement = announcements[channel]
|
||||
|
||||
if (!announcement) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live={announcement.politeness}
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
data-announce-id={announcement.id}
|
||||
data-announce-channel={channel}
|
||||
>
|
||||
{announcement.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user