Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f1122def1 | |||
| 5bac9a1d59 | |||
| 61aa3c79c5 | |||
| 0c6cd8223d | |||
| b692314646 | |||
| e816475981 | |||
| 8d1f01645a | |||
| 29a46a413e | |||
| b618b2347f | |||
| 7038f2a85d | |||
| b7dfafaf47 | |||
| 2a7d963f50 | |||
| 980117132f | |||
| 7a8b50b5f0 | |||
| 409e403581 | |||
| 0c0eed6f59 | |||
| 750b7f86b4 | |||
| 9569c58315 | |||
| 922a968418 | |||
| 48e6cef763 | |||
| ac4ec6c752 | |||
| 7454d44329 | |||
| 1e3e7de753 | |||
| 4c2eb31a6a | |||
| a2a44a6546 | |||
| f1fa99f918 | |||
| 4a6e65d4be | |||
| c8772bb1ad | |||
| d54925bd97 | |||
| e9f3e27058 | |||
| 0abbe4a26f | |||
| 3e93f5924c | |||
| d70dc41643 | |||
| 6e81b55403 | |||
| b8cc21debc | |||
| 36ddb84982 | |||
| 496ae12fa9 | |||
| ae4ef48d05 | |||
| 952104fd82 | |||
| 60dc8bf174 | |||
| d54a61cbcc | |||
| f90a1e3549 | |||
| 572b80d3fe | |||
| 82d840a15f | |||
| 500c690fa0 | |||
| 577111d864 | |||
| 563f1e4c0f | |||
| 2e8407ac7c | |||
| 2af9ec0d85 | |||
| b70799c2db | |||
| 0facfc11be | |||
| 8023e44f71 | |||
| b27c5e9b92 | |||
| 3e5c4c32e9 | |||
| 551207ab86 | |||
| 0aa47fcd1e | |||
| c289f79d8e | |||
| b79fa14919 | |||
| c7c0df5b6d | |||
| cb00347be6 | |||
| bcb004ab4b | |||
| 422f838899 | |||
| 462c6c50e5 | |||
| 63565b38c3 | |||
| 10d759bdbb | |||
| 51f1f0ebbf | |||
| 9e27d0f345 | |||
| 978d931bd7 | |||
| 0c811222d4 | |||
| 94171dcb82 | |||
| 56c1cd98fa | |||
| f2e6edb90d | |||
| bc76c44fe9 | |||
| e519f00342 | |||
| 2246bb7782 | |||
| e210f26f9c | |||
| 888dfe76c7 | |||
| ae17fbdaa8 | |||
| 2ef95aa835 | |||
| a83e5c4b1c | |||
| 9cc79ba159 | |||
| c63adf9c8c |
@@ -249,67 +249,3 @@ jobs:
|
||||
|
||||
- name: Build SDK
|
||||
run: npm run build
|
||||
|
||||
i18n-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "infrastructure,secrets"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
- name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
|
||||
- name: Install gettext (required to make messages)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install development dependencies
|
||||
working-directory: src/backend
|
||||
run: pip install --user .[dev]
|
||||
|
||||
- name: Generate the translation base file
|
||||
run: ~/.local/bin/django-admin makemessages --keep-pot --all
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18.x"
|
||||
cache: "npm"
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && npm ci
|
||||
|
||||
- name: Extract the frontend translation
|
||||
run: make frontend-i18n-extract
|
||||
|
||||
- name: Upload files to Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_BASE_PATH: ${{ github.workspace }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -49,7 +49,7 @@ clean_old_images('localhost:5001/meet-summary')
|
||||
# This is necessary because we need to inject the certificate into our LiveKit container
|
||||
local_resource(
|
||||
'copy-root-ca',
|
||||
cmd='cp "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
|
||||
cmd='cp -f "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
|
||||
deps=[], # No dependencies needed
|
||||
)
|
||||
# Build a custom LiveKit Docker image that includes our root CA certificate
|
||||
|
||||
@@ -72,6 +72,8 @@ services:
|
||||
- nginx
|
||||
- livekit
|
||||
- createbuckets
|
||||
extra_hosts:
|
||||
- "127.0.0.1.nip.io:host-gateway"
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
|
||||
|
||||
## Prerequisites
|
||||
## Prerequisites for a kubernetes setup
|
||||
|
||||
- k8s cluster with an nginx-ingress controller
|
||||
- an OIDC provider (if you don't have one, we will provide an example)
|
||||
@@ -12,14 +12,25 @@ This document is a step-by-step guide that describes how to install Visio on a k
|
||||
|
||||
### Test cluster
|
||||
|
||||
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
|
||||
If you do not have a kubernetes test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script located in this repo under **bin/start-kind.sh**.
|
||||
|
||||
To be able to use the script, you will need to install:
|
||||
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
|
||||
|
||||
To be able to use the script, you will need to install the following components:
|
||||
|
||||
- Docker (https://docs.docker.com/desktop/)
|
||||
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
|
||||
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
|
||||
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
|
||||
- kubectl (https://kubernetes.io/docs/tasks/tools/)
|
||||
|
||||
In order to initiate the local kind installation via **start-kind.sh** do the following:
|
||||
1) Make sure administrator/root user context is able to execute mkcert, docker, kind etc. commands or the script might fail
|
||||
2) Download the script to the device where the above components are installed
|
||||
3) Make the script executable
|
||||
4) Run the script with proper permissions (administrator/sudo etc.)
|
||||
|
||||
The output of the script will resemble the below example:
|
||||
|
||||
```
|
||||
$ ./bin/start-kind.sh
|
||||
@@ -99,7 +110,7 @@ When your k8s cluster is ready, you can start the deployment. This cluster is sp
|
||||
|
||||
Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
||||
|
||||
## Preparation
|
||||
## Preparation of components
|
||||
|
||||
### What will you use to authenticate your users ?
|
||||
|
||||
@@ -111,7 +122,7 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
|
||||
$ kubectl create namespace meet
|
||||
```
|
||||
|
||||
If you have already run the script, you can skip this step and proceed to the next instruction.
|
||||
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
|
||||
|
||||
```
|
||||
$ kubectl config set-context --current --namespace=meet
|
||||
@@ -232,7 +243,7 @@ meet <none> meet.127.0.0.1.nip.io localhost 80, 44
|
||||
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
|
||||
```
|
||||
|
||||
You can use Visio on https://meet.127.0.0.1.nip.io. The provisioning user in keycloak is meet/meet.
|
||||
You can use Visio on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
|
||||
|
||||
## All options
|
||||
|
||||
@@ -240,91 +251,110 @@ These are the environmental options available on meet backend.
|
||||
|
||||
| Option | Description | default |
|
||||
| ----------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| DJANGO_ALLOWED_HOSTS | Host that are allowed | [] |
|
||||
| DJANGO_SECRET_KEY | Secret key used | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence system checks | [] |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | | false |
|
||||
| DATA_DIR | Data directory location | /data |
|
||||
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
|
||||
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_NAME | name of the database | meet |
|
||||
| DB_USER | user used to connect to database | dinum |
|
||||
| DB_PASSWORD | password used to connect to the database | pass |
|
||||
| DB_HOST | hostname of the database | localhost |
|
||||
| DB_PORT | port to connect to database | 5432 |
|
||||
| DB_NAME | Name of the database | meet |
|
||||
| DB_USER | User used to connect to database | dinum |
|
||||
| DB_PASSWORD | Password used to connect to the database | pass |
|
||||
| DB_HOST | Hostname of the database | localhost |
|
||||
| DB_PORT | Port to connect to database | 5432 |
|
||||
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
|
||||
| AWS_S3_ACCESS_KEY_ID | s3 access key | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | s3 secret key | |
|
||||
| AWS_S3_REGION_NAME | s3 region | |
|
||||
| AWS_STORAGE_BUCKET_NAME | s3 bucket name | |
|
||||
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
|
||||
| AWS_S3_REGION_NAME | S3 region | |
|
||||
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
|
||||
| DJANGO_LANGUAGE_CODE | Default language | en-us |
|
||||
| REDIS_URL | redis endpoint | redis://redis:6379/1 |
|
||||
| REQUEST_ENTRY_THROTTLE_RATES | throttle rates | 150/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | deploy check | False |
|
||||
| FRONTEND_ANALYTICS | analytics information | {} |
|
||||
| FRONTEND_SUPPORT | crisp frontend support | {} |
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | silence livekit debug | false |
|
||||
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
| DJANGO_EMAIL_HOST | host of the email server | |
|
||||
| DJANGO_EMAIL_HOST_USER | user to connect to the email server | |
|
||||
| DJANGO_EMAIL_HOST_PASSWORD | password to connect tto the email server | |
|
||||
| DJANGO_EMAIL_PORT | por tot connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | enable tls on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | enable tls on email connection | false |
|
||||
| DJANGO_EMAIL_FROM | email from account | from@example.com |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all cors origins | true |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | origins to allow in string | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | origins to allow in regex | [] |
|
||||
| SENTRY_DSN | sentry server | |
|
||||
| DJANGO_CELERY_BROKER_URL | celery broker host | redis://redis:6379/0 |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker options | {} |
|
||||
| OIDC_CREATE_USER | create oidc user if not exists | false |
|
||||
| OIDC_VERIFY_SSL | verify ssl for oidc | true |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | fallback to email for identification | false |
|
||||
| OIDC_RP_SIGN_ALGO | token verification algoritm used by oidc | RS256 |
|
||||
| OIDC_RP_CLIENT_ID | oidc client | meet |
|
||||
| OIDC_RP_CLIENT_SECRET | oidc client secret | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | oidc endpoint for JWKS | |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | oidc endpoint for authorization | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | oidc endpoint for token | |
|
||||
| OIDC_OP_USER_ENDPOINT | oidc endpoint for user | |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | oidc endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
|
||||
| OIDC_RP_SCOPES | oidc scopes | openid email |
|
||||
| LOGIN_REDIRECT_URL | login redirect url | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | login redurect url for failure | |
|
||||
| LOGOUT_REDIRECT_URL | url to redirect to on logout | |
|
||||
| OIDC_USE_NONCE | use nonce for oidc | true |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | require https for oidc | false |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | allowed redirect hosts for oidc | [] |
|
||||
| OIDC_STORE_ID_TOKEN | store oidc ID token | true |
|
||||
| ALLOW_LOGOUT_GET_METHOD | allow logout through get method | true |
|
||||
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
|
||||
| LIVEKIT_API_KEY | livekit api key | |
|
||||
| LIVEKIT_API_SECRET | livekit api secret | |
|
||||
| LIVEKIT_API_URL | livekit api url | |
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | default resource access level for rooms | public |
|
||||
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
|
||||
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
|
||||
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
|
||||
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
|
||||
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
|
||||
| FRONTEND_ANALYTICS | Analytics information | {} |
|
||||
| FRONTEND_SUPPORT | Crisp frontend support configuration | {} |
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
| DJANGO_EMAIL_HOST | Host of the email server | |
|
||||
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
|
||||
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
|
||||
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
||||
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
||||
| EMAIL_BRAND_NAME | Email branding name | |
|
||||
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
||||
| EMAIL_LOGO_IMG | Email logo image | |
|
||||
| EMAIL_DOMAIN | Email domain | |
|
||||
| EMAIL_APP_BASE_URL | Email app base URL | |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | true |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
|
||||
| SENTRY_DSN | Sentry server DSN | |
|
||||
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
|
||||
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
|
||||
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
|
||||
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
|
||||
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
|
||||
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
|
||||
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
|
||||
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
|
||||
| OIDC_RP_SCOPES | OIDC scopes | openid email |
|
||||
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
|
||||
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
|
||||
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
|
||||
| LOGIN_REDIRECT_URL | Login redirect URL | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
|
||||
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
|
||||
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
|
||||
| LIVEKIT_API_KEY | LiveKit API key | |
|
||||
| LIVEKIT_API_SECRET | LiveKit API secret | |
|
||||
| LIVEKIT_API_URL | LiveKit API URL | |
|
||||
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| RECORDING_ENABLE | record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | folder to store meetings | recordings |
|
||||
| RECORDING_VERIFY_SSL | verify ssl for recording storage | true |
|
||||
| RECORDING_WORKER_CLASSES | worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EVENT_PARSER_CLASS | storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | enable storage event authorization | true |
|
||||
| RECORDING_STORAGE_EVENT_ENABLE | enable recording storage events | false |
|
||||
| RECORDING_STORAGE_EVENT_TOKEN | recording storage event token | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | api token voor summary service | |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | markering class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | breva api key for marketing emails | |
|
||||
| BREVO_API_CONTACT_LIST_IDS | brevo api contact list ids | [] |
|
||||
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | brevo contact attributes | {"VISIO_USER": True} |
|
||||
| BREVO_API_TIMEOUT | brevo timeout | 1 |
|
||||
| LOBBY_KEY_PREFIX | lobby prefix | room_lobby |
|
||||
| LOBBY_WAITING_TIMEOUT | lobby waiting tumeout | 3 |
|
||||
| LOBBY_DENIED_TIMEOUT | lobby deny timeout | 5 |
|
||||
| LOBBY_ACCEPTED_TIMEOUT | lobby accept timeout | 21600 |
|
||||
| LOBBY_NOTIFICATION_TYPE | lobby notification types | participantWaiting |
|
||||
| LOBBY_COOKIE_NAME | lobby cookie name | lobbyParticipantId |
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
|
||||
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
|
||||
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
|
||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
||||
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
|
||||
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
|
||||
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
|
||||
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
|
||||
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
|
||||
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
|
||||
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
|
||||
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
|
||||
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
|
||||
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
|
||||
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
|
||||
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
|
||||
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
|
||||
|
||||
@@ -48,8 +48,12 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
# Livekit Token settings
|
||||
LIVEKIT_API_SECRET=secret
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_URL=http://localhost:7880
|
||||
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
|
||||
LIVEKIT_VERIFY_SSL=False
|
||||
ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
# Recording
|
||||
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
|
||||
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
@@ -99,6 +99,7 @@ class ResourceAccessInline(admin.TabularInline):
|
||||
|
||||
model = models.ResourceAccess
|
||||
extra = 0
|
||||
autocomplete_fields = ["user"]
|
||||
|
||||
|
||||
@admin.register(models.Room)
|
||||
@@ -106,6 +107,10 @@ class RoomAdmin(admin.ModelAdmin):
|
||||
"""Room admin interface declaration."""
|
||||
|
||||
inlines = (ResourceAccessInline,)
|
||||
search_fields = ["name", "slug", "=id"]
|
||||
list_display = ["name", "slug", "access_level", "created_at"]
|
||||
list_filter = ["access_level", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class RecordingAccessInline(admin.TabularInline):
|
||||
@@ -120,4 +125,28 @@ class RecordingAdmin(admin.ModelAdmin):
|
||||
"""Recording admin interface declaration."""
|
||||
|
||||
inlines = (RecordingAccessInline,)
|
||||
list_display = ("id", "status", "room", "created_at", "worker_id")
|
||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
||||
list_display = ("id", "status", "room", "get_owner", "created_at", "worker_id")
|
||||
list_filter = ["status", "room", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
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 recording for display in the admin list."""
|
||||
|
||||
owners = [
|
||||
access
|
||||
for access in obj.accesses.all()
|
||||
if access.role == models.RoleChoices.OWNER
|
||||
]
|
||||
|
||||
if not owners:
|
||||
return _("No owner")
|
||||
|
||||
if len(owners) > 1:
|
||||
return _("Multiple owners")
|
||||
|
||||
return str(owners[0].user)
|
||||
|
||||
@@ -37,7 +37,7 @@ class ResourceAccessSerializerMixin:
|
||||
# Update
|
||||
self.instance
|
||||
and (
|
||||
data["role"] == models.RoleChoices.OWNER
|
||||
data.get("role") == models.RoleChoices.OWNER
|
||||
and not self.instance.resource.is_owner(user)
|
||||
or self.instance.role == models.RoleChoices.OWNER
|
||||
and not self.instance.user == user
|
||||
@@ -120,7 +120,7 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
role = instance.get_role(request.user)
|
||||
is_admin = models.RoleChoices.check_administrator_role(role)
|
||||
|
||||
if role is not None:
|
||||
if is_admin:
|
||||
access_serializer = NestedResourceAccessSerializer(
|
||||
instance.accesses.select_related("resource", "user").all(),
|
||||
context=self.context,
|
||||
|
||||
@@ -531,40 +531,12 @@ class RoomViewSet(
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list rooms access"""
|
||||
if self.action == "list":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
return super().get_permissions()
|
||||
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
queryset = super().get_queryset()
|
||||
if self.action == "list":
|
||||
user = self.request.user
|
||||
queryset = queryset.filter(
|
||||
Q(resource__accesses__user=user),
|
||||
resource__accesses__role__in=[
|
||||
models.RoleChoices.ADMIN,
|
||||
models.RoleChoices.OWNER,
|
||||
],
|
||||
).distinct()
|
||||
return queryset
|
||||
|
||||
|
||||
class ResourceAccessViewSet(
|
||||
ResourceAccessListModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
@@ -575,6 +547,25 @@ class ResourceAccessViewSet(
|
||||
queryset = models.ResourceAccess.objects.all()
|
||||
serializer_class = serializers.ResourceAccessSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Restrict access to resources the user either has explicit
|
||||
# permissions for or administrative privileges over.
|
||||
if self.action == "list":
|
||||
user = self.request.user
|
||||
queryset = queryset.filter(
|
||||
Q(resource__accesses__user=user),
|
||||
resource__accesses__role__in=[
|
||||
models.RoleChoices.ADMIN,
|
||||
models.RoleChoices.OWNER,
|
||||
],
|
||||
).distinct()
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
import contextlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
@@ -17,93 +18,46 @@ from core.services.marketing import (
|
||||
)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
"""Return user details dictionary.
|
||||
def get_extra_claims(self, user_info):
|
||||
"""
|
||||
Return extra claims from user_info.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The id token (unused).
|
||||
- payload (dict): The token payload (unused).
|
||||
|
||||
Note: The id_token and payload parameters are unused in this implementation,
|
||||
but were kept to preserve base method signature.
|
||||
|
||||
Note: It handles signed and/or encrypted UserInfo Response. It is required by
|
||||
Agent Connect, which follows the OIDC standard. It forces us to override the
|
||||
base method, which deal with 'application/json' response.
|
||||
Args:
|
||||
user_info (dict): The user information dictionary.
|
||||
|
||||
Returns:
|
||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
||||
dict: A dictionary of extra claims.
|
||||
|
||||
"""
|
||||
|
||||
user_response = requests.get(
|
||||
self.OIDC_OP_USER_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
verify=self.get_settings("OIDC_VERIFY_SSL", True),
|
||||
timeout=self.get_settings("OIDC_TIMEOUT", None),
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The ID token.
|
||||
- payload (dict): The user payload.
|
||||
|
||||
Returns:
|
||||
- User: An existing or newly created User instance.
|
||||
|
||||
Raises:
|
||||
- Exception: Raised when user creation is not allowed and no existing user is found.
|
||||
"""
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
sub = user_info.get("sub")
|
||||
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
email = user_info.get("email")
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
claims = {
|
||||
"email": email,
|
||||
return {
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
"full_name": self.compute_full_name(user_info),
|
||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||
}
|
||||
if not user and self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(
|
||||
sub=sub,
|
||||
password="!", # noqa: S106
|
||||
**claims,
|
||||
)
|
||||
|
||||
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
def post_get_or_create_user(self, user, claims, is_new_user):
|
||||
"""
|
||||
Post-processing after user creation or retrieval.
|
||||
|
||||
elif not user:
|
||||
return None
|
||||
Args:
|
||||
user (User): The user instance.
|
||||
claims (dict): The claims dictionary.
|
||||
is_new_user (bool): Indicates if the user was newly created.
|
||||
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
Returns:
|
||||
- None
|
||||
|
||||
self.update_user_if_needed(user, claims)
|
||||
|
||||
return user
|
||||
"""
|
||||
email = claims["email"]
|
||||
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
@@ -116,7 +70,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
||||
"""
|
||||
try:
|
||||
with contextlib.suppress(
|
||||
ContactCreationError, ImproperlyConfigured, ImportError
|
||||
):
|
||||
marketing_service = get_marketing_service()
|
||||
contact_data = ContactData(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
@@ -124,8 +80,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
marketing_service.create_contact(
|
||||
contact_data, timeout=settings.BREVO_API_TIMEOUT
|
||||
)
|
||||
except (ContactCreationError, ImproperlyConfigured, ImportError):
|
||||
pass
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
@@ -139,35 +93,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
pass
|
||||
except User.MultipleObjectsReturned as e:
|
||||
raise SuspiciousOperation(
|
||||
_("Multiple user accounts share a common email.")
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def compute_full_name(user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
full_name = " ".join(
|
||||
filter(
|
||||
None,
|
||||
(
|
||||
user_info.get(field)
|
||||
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
|
||||
),
|
||||
)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
@staticmethod
|
||||
def update_user_if_needed(user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
user_fields = vars(user.__class__) # Get available model fields
|
||||
updated_claims = {
|
||||
key: value
|
||||
for key, value in claims.items()
|
||||
if value and key in user_fields and value != getattr(user, key)
|
||||
}
|
||||
|
||||
if not updated_claims:
|
||||
return
|
||||
|
||||
User.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Authentication URLs for the People core app."""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
|
||||
|
||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
urlpatterns = [
|
||||
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
|
||||
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
|
||||
path(
|
||||
"logout-callback/",
|
||||
OIDCLogoutCallbackView.as_view(),
|
||||
name="oidc_logout_callback",
|
||||
),
|
||||
*mozzila_oidc_urls,
|
||||
]
|
||||
@@ -1,181 +0,0 @@
|
||||
"""Authentication Views for the People core app."""
|
||||
|
||||
import copy
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
from mozilla_django_oidc.utils import (
|
||||
absolutify,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||
)
|
||||
|
||||
|
||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||
|
||||
Adds support for handling logout callbacks from the identity provider (OP)
|
||||
by initiating the logout flow if the user has an active session.
|
||||
|
||||
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
|
||||
This parameter is crucial for maintaining the integrity of the logout flow between this call
|
||||
and the subsequent callback.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def persist_state(request, state):
|
||||
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
|
||||
|
||||
This method is used to store the OIDC state parameter in the session, according to the
|
||||
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
|
||||
utility function.
|
||||
"""
|
||||
|
||||
if "oidc_states" not in request.session or not isinstance(
|
||||
request.session["oidc_states"], dict
|
||||
):
|
||||
request.session["oidc_states"] = {}
|
||||
|
||||
request.session["oidc_states"][state] = {}
|
||||
request.session.save()
|
||||
|
||||
def construct_oidc_logout_url(self, request):
|
||||
"""Create the redirect URL for interfacing with the OIDC provider.
|
||||
|
||||
Retrieves the necessary parameters from the session and constructs the URL
|
||||
required to initiate logout with the OpenID Connect provider.
|
||||
|
||||
If no ID token is found in the session, the logout flow will not be initiated,
|
||||
and the method will return the default redirect URL.
|
||||
|
||||
The 'state' parameter is generated randomly and persisted in the session to ensure
|
||||
its integrity during the subsequent callback.
|
||||
"""
|
||||
|
||||
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
|
||||
|
||||
if not oidc_logout_endpoint:
|
||||
return self.redirect_url
|
||||
|
||||
reverse_url = reverse("oidc_logout_callback")
|
||||
id_token = request.session.get("oidc_id_token", None)
|
||||
|
||||
if not id_token:
|
||||
return self.redirect_url
|
||||
|
||||
query = {
|
||||
"id_token_hint": id_token,
|
||||
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
|
||||
"post_logout_redirect_uri": absolutify(request, reverse_url),
|
||||
}
|
||||
|
||||
self.persist_state(request, query["state"])
|
||||
|
||||
return f"{oidc_logout_endpoint}?{urlencode(query)}"
|
||||
|
||||
def post(self, request):
|
||||
"""Handle user logout.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, constructs the OIDC logout URL and redirects the user to start
|
||||
the logout process.
|
||||
|
||||
If the user is redirected to the default logout URL, ensure her Django session
|
||||
is terminated.
|
||||
"""
|
||||
|
||||
logout_url = self.redirect_url
|
||||
|
||||
if request.user.is_authenticated:
|
||||
logout_url = self.construct_oidc_logout_url(request)
|
||||
|
||||
# If the user is not redirected to the OIDC provider, ensure logout
|
||||
if logout_url == self.redirect_url:
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(logout_url)
|
||||
|
||||
|
||||
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
|
||||
|
||||
Handles the callback after logout from the identity provider (OP).
|
||||
Verifies the state parameter and performs necessary logout actions.
|
||||
|
||||
The Django session is maintained during the logout process to ensure the integrity
|
||||
of the logout flow initiated in the previous step.
|
||||
"""
|
||||
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request):
|
||||
"""Handle the logout callback.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, verifies the state parameter and performs necessary logout actions.
|
||||
"""
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
state = request.GET.get("state")
|
||||
|
||||
if state not in request.session.get("oidc_states", {}):
|
||||
msg = "OIDC callback state not found in session `oidc_states`!"
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
del request.session["oidc_states"][state]
|
||||
request.session.save()
|
||||
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
|
||||
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||
"""Custom callback view for handling the silent login flow."""
|
||||
|
||||
@property
|
||||
def failure_url(self):
|
||||
"""Override the failure URL property to handle silent login flow
|
||||
|
||||
A silent login failure (e.g., no active user session) should not be
|
||||
considered as an authentication failure.
|
||||
"""
|
||||
if self.request.session.get("silent", None):
|
||||
del self.request.session["silent"]
|
||||
self.request.session.save()
|
||||
return self.success_url
|
||||
return super().failure_url
|
||||
|
||||
|
||||
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
|
||||
"""Custom authentication view for handling the silent login flow."""
|
||||
|
||||
def get_extra_params(self, request):
|
||||
"""Handle 'prompt' extra parameter for the silent login flow
|
||||
|
||||
This extra parameter is necessary to distinguish between a standard
|
||||
authentication flow and the silent login flow.
|
||||
"""
|
||||
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
|
||||
if extra_params is None:
|
||||
extra_params = {}
|
||||
if request.GET.get("silent") == "true":
|
||||
extra_params = copy.deepcopy(extra_params)
|
||||
extra_params.update({"prompt": "none"})
|
||||
request.session["silent"] = True
|
||||
request.session.save()
|
||||
return extra_params
|
||||
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.1.9 on 2025-05-13 08:22
|
||||
|
||||
import secrets
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def generate_pin_for_rooms(apps, schema_editor):
|
||||
"""Generate unique 10-digit PIN codes for existing rooms.
|
||||
The PIN code is required for SIP telephony features.
|
||||
"""
|
||||
Room = apps.get_model('core', 'Room')
|
||||
rooms_without_pin_code = Room.objects.filter(pin_code__isnull=True)
|
||||
existing_pins = set(Room.objects.values_list('pin_code', flat=True))
|
||||
|
||||
length = 10
|
||||
|
||||
def generate_pin_code():
|
||||
while True:
|
||||
pin_code = str(secrets.randbelow(10 ** length)).zfill(length)
|
||||
if not pin_code in existing_pins:
|
||||
return pin_code
|
||||
|
||||
for room in rooms_without_pin_code:
|
||||
room.pin_code = generate_pin_code()
|
||||
room.save()
|
||||
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0013_alter_user_language'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='pin_code',
|
||||
field=models.CharField(blank=True, help_text='Unique n-digit code that identifies this room in telephony mode.', max_length=None, null=True, unique=True, verbose_name='Room PIN code'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
generate_pin_for_rooms,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -2,6 +2,7 @@
|
||||
Declare and configure the models for the Meet core application
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from logging import getLogger
|
||||
@@ -14,7 +15,6 @@ from django.core import mail, validators
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -164,7 +164,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
)
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||
choices=settings.LANGUAGES,
|
||||
default=settings.LANGUAGE_CODE,
|
||||
verbose_name=_("language"),
|
||||
help_text=_("The language in which the user wants to see the interface."),
|
||||
@@ -383,6 +383,14 @@ class Room(Resource):
|
||||
verbose_name=_("Visio room configuration"),
|
||||
help_text=_("Values for Visio parameters to configure the room."),
|
||||
)
|
||||
pin_code = models.CharField(
|
||||
max_length=None,
|
||||
unique=True,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Room PIN code"),
|
||||
help_text=_("Unique n-digit code that identifies this room in telephony mode."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_room"
|
||||
@@ -393,6 +401,14 @@ class Room(Resource):
|
||||
def __str__(self):
|
||||
return capfirst(self.name)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Generate a unique n-digit pin code for new rooms."""
|
||||
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
||||
self.pin_code = self.generate_unique_pin_code(
|
||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
"""
|
||||
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
||||
@@ -407,6 +423,7 @@ class Room(Resource):
|
||||
pass
|
||||
else:
|
||||
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
||||
|
||||
super().clean_fields(exclude=exclude)
|
||||
|
||||
@property
|
||||
@@ -414,6 +431,31 @@ class Room(Resource):
|
||||
"""Check if a room is public"""
|
||||
return self.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_pin_code(length):
|
||||
"""Generate a unique n-digit PIN code"""
|
||||
|
||||
if length < 4:
|
||||
raise ValueError(
|
||||
"PIN code length must be at least 4 digits for minimal security"
|
||||
)
|
||||
|
||||
max_value = 10**length
|
||||
|
||||
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
|
||||
pin_code = str(secrets.randbelow(max_value)).zfill(length)
|
||||
if not Room.objects.filter(pin_code=pin_code).exists():
|
||||
return pin_code
|
||||
|
||||
# Log a warning as a temporary measure until backend observability is implemented.
|
||||
logger.warning(
|
||||
"Failed to generate unique PIN code of length %s after %s attempts",
|
||||
length,
|
||||
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class BaseAccessManager(models.Manager):
|
||||
"""Base manager for handling resource access control."""
|
||||
|
||||
@@ -55,7 +55,7 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
if not required_token:
|
||||
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
raise AuthenticationFailed(
|
||||
_("Authentication is enabled but token is not configured.")
|
||||
"Authentication is enabled but token is not configured."
|
||||
)
|
||||
|
||||
return MachineUser(), None
|
||||
@@ -67,7 +67,7 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
"Authentication failed: Missing Authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed(_("Authorization header is required"))
|
||||
raise AuthenticationFailed("Authorization header is required")
|
||||
|
||||
auth_parts = auth_header.split(" ")
|
||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||
@@ -75,7 +75,7 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
"Authentication failed: Invalid authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed(_("Invalid authorization header."))
|
||||
raise AuthenticationFailed("Invalid authorization header.")
|
||||
|
||||
token = auth_parts[1]
|
||||
|
||||
@@ -85,7 +85,7 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
"Authentication failed: Invalid token (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed(_("Invalid token"))
|
||||
raise AuthenticationFailed("Invalid token")
|
||||
|
||||
return MachineUser(), token
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ class WorkerServiceConfig:
|
||||
|
||||
output_folder: str
|
||||
server_configurations: Dict[str, Any]
|
||||
verify_ssl: Optional[bool]
|
||||
bucket_args: Optional[dict]
|
||||
|
||||
@classmethod
|
||||
@@ -29,7 +28,6 @@ class WorkerServiceConfig:
|
||||
return cls(
|
||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||
server_configurations=settings.LIVEKIT_CONFIGURATION,
|
||||
verify_ssl=settings.RECORDING_VERIFY_SSL,
|
||||
bucket_args={
|
||||
"endpoint": settings.AWS_S3_ENDPOINT_URL,
|
||||
"access_key": settings.AWS_S3_ACCESS_KEY_ID,
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import aiohttp
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
from livekit.api.egress_service import EgressService
|
||||
|
||||
from ... import utils
|
||||
from ..enums import FileExtension
|
||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from .factories import WorkerServiceConfig
|
||||
@@ -29,21 +28,26 @@ class BaseEgressService:
|
||||
async def _handle_request(self, request, method_name: str):
|
||||
"""Handle making a request to the LiveKit API and returns the response."""
|
||||
|
||||
# Use HTTP connector for local development with Tilt,
|
||||
# where cluster communications are unsecure
|
||||
connector = aiohttp.TCPConnector(ssl=self._config.verify_ssl)
|
||||
lkapi = utils.create_livekit_client(self._config.server_configurations)
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
client = EgressService(session, **self._config.server_configurations)
|
||||
method = getattr(client, method_name)
|
||||
try:
|
||||
response = await method(request)
|
||||
except livekit_api.TwirpError as e:
|
||||
raise WorkerConnectionError(
|
||||
f"LiveKit client connection error, {e.message}."
|
||||
) from e
|
||||
# ruff: noqa: SLF001
|
||||
# pylint: disable=protected-access
|
||||
method = getattr(lkapi._egress, method_name)
|
||||
|
||||
try:
|
||||
response = await method(request)
|
||||
return response
|
||||
except livekit_api.TwirpError as e:
|
||||
raise WorkerConnectionError(
|
||||
f"LiveKit client connection error, {e.message}."
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise WorkerConnectionError(
|
||||
f"Unexpected error during LiveKit client connection: {str(e)}"
|
||||
) from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
"""Stop an ongoing egress worker.
|
||||
|
||||
@@ -14,7 +14,6 @@ from django.core.cache import cache
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
ListRoomsRequest,
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
)
|
||||
@@ -347,7 +346,7 @@ class LobbyService:
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
}
|
||||
|
||||
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
room_response = await lkapi.room.list_rooms(
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import brevo_python
|
||||
import urllib3
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -120,8 +121,11 @@ class BrevoMarketingService:
|
||||
|
||||
try:
|
||||
response = contact_api.create_contact(contact, **api_configurations)
|
||||
except brevo_python.rest.ApiException as err:
|
||||
logger.exception("Failed to create contact in Brevo")
|
||||
except (
|
||||
brevo_python.rest.ApiException,
|
||||
urllib3.exceptions.ReadTimeoutError,
|
||||
) as err:
|
||||
logger.warning("Failed to create contact in Brevo", exc_info=True)
|
||||
raise ContactCreationError("Failed to create contact in Brevo") from err
|
||||
|
||||
return response
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
assert user.email == email
|
||||
assert user.full_name is None
|
||||
assert user.short_name is None
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ def test_authentication_getter_new_user_with_names(monkeypatch, email):
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
match="Claims verification failed",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Unit tests for the Authentication URLs."""
|
||||
|
||||
from core.authentication.urls import urlpatterns
|
||||
|
||||
|
||||
def test_urls_override_default_mozilla_django_oidc():
|
||||
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
|
||||
|
||||
url_names = [u.name for u in urlpatterns]
|
||||
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
|
||||
@@ -1,359 +0,0 @@
|
||||
"""Unit tests for the Authentication Views."""
|
||||
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.authentication.views import (
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationRequestView,
|
||||
OIDCLogoutCallbackView,
|
||||
OIDCLogoutView,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_anonymous():
|
||||
"""Anonymous users calling the logout url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
|
||||
)
|
||||
def test_view_logout(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = client.get(url)
|
||||
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
|
||||
)
|
||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
response = client.get(url)
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/default-redirect-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback_anonymous():
|
||||
"""Anonymous users calling the logout callback url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_oidc_states",
|
||||
[{}, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_persist_state(initial_oidc_states):
|
||||
"""State value should be persisted in session's data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_oidc_states:
|
||||
request.session["oidc_states"] = initial_oidc_states
|
||||
request.session.save()
|
||||
|
||||
mocked_state = "mock_state"
|
||||
|
||||
OIDCLogoutView().persist_state(request, mocked_state)
|
||||
|
||||
assert "oidc_states" in request.session
|
||||
assert request.session["oidc_states"] == {
|
||||
"mock_state": {},
|
||||
**initial_oidc_states,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
|
||||
@mock.patch.object(OIDCLogoutView, "persist_state")
|
||||
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
|
||||
def test_view_logout_construct_oidc_logout_url(
|
||||
mocked_get_random_string, mocked_persist_state
|
||||
):
|
||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["oidc_id_token"] = "mocked_oidc_id_token"
|
||||
request.session.save()
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
mocked_persist_state.assert_called_once()
|
||||
mocked_get_random_string.assert_called_once()
|
||||
|
||||
params = parse_qs(urlparse(redirect_url).query)
|
||||
|
||||
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
|
||||
assert params["state"][0] == "mocked_state"
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
assert url in params["post_logout_redirect_uri"][0]
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/")
|
||||
def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
"""If no ID token is available in the session,
|
||||
the user should be redirected to the final URL."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
assert redirect_url == "/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_state",
|
||||
[None, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_callback_wrong_state(initial_state):
|
||||
"""Should raise an error if OIDC state doesn't match session data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_state:
|
||||
request.session["oidc_states"] = initial_state
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with pytest.raises(SuspiciousOperation) as excinfo:
|
||||
callback_view(request)
|
||||
|
||||
assert (
|
||||
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback():
|
||||
"""If state matches, callback should clear OIDC state and redirects."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
mocked_state = "mocked_state"
|
||||
|
||||
request.session["oidc_states"] = {mocked_state: {}}
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
|
||||
def clear_user(request):
|
||||
# Assert state is cleared prior to logout
|
||||
assert request.session["oidc_states"] == {}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
mock_logout.side_effect = clear_user
|
||||
response = callback_view(request)
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_default(settings, mocked_extra_params_setting):
|
||||
"""By default, authentication request should not trigger silent login."""
|
||||
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {}
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
|
||||
assert extra_params == (mocked_extra_params_setting or {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
|
||||
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
|
||||
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {"silent": "foo"}
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
|
||||
assert extra_params == (mocked_extra_params_setting or {})
|
||||
assert not request.session.get("silent")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
|
||||
"""If 'silent' parameter is set to True, the silent login should be triggered."""
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {"silent": "true"}
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
expected_params = {"prompt": "none"}
|
||||
|
||||
assert (
|
||||
extra_params == {**mocked_extra_params_setting, **expected_params}
|
||||
if mocked_extra_params_setting
|
||||
else expected_params
|
||||
)
|
||||
assert request.session.get("silent") is True
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
"failure_url",
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value="foo",
|
||||
)
|
||||
def test_view_callback_failure_url(mocked_failure_url):
|
||||
"""Test default behavior of the 'failure_url' property"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationCallbackView()
|
||||
view.request = request
|
||||
|
||||
returned_url = view.failure_url
|
||||
|
||||
mocked_failure_url.assert_called_once()
|
||||
assert returned_url == "foo"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCAuthenticationCallbackView,
|
||||
"success_url",
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value="foo",
|
||||
)
|
||||
def test_view_callback_failure_url_silent_login(mocked_success_url):
|
||||
"""If a silent login was initiated and failed, it should not be treated as a failure."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["silent"] = True
|
||||
request.session.save()
|
||||
|
||||
view = OIDCAuthenticationCallbackView()
|
||||
view.request = request
|
||||
|
||||
returned_url = view.failure_url
|
||||
|
||||
mocked_success_url.assert_called_once()
|
||||
assert returned_url == "foo"
|
||||
assert not request.session.get("silent")
|
||||
@@ -32,7 +32,6 @@ def test_settings():
|
||||
mocked_settings = {
|
||||
"RECORDING_OUTPUT_FOLDER": "/test/output",
|
||||
"LIVEKIT_CONFIGURATION": {"server": "test.example.com"},
|
||||
"RECORDING_VERIFY_SSL": True,
|
||||
"AWS_S3_ENDPOINT_URL": "https://s3.test.com",
|
||||
"AWS_S3_ACCESS_KEY_ID": "test_key",
|
||||
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
||||
@@ -56,7 +55,6 @@ def test_config_initialization(default_config):
|
||||
"""Test that WorkerServiceConfig is properly initialized from settings"""
|
||||
assert default_config.output_folder == "/test/output"
|
||||
assert default_config.server_configurations == {"server": "test.example.com"}
|
||||
assert default_config.verify_ssl is True
|
||||
assert default_config.bucket_args == {
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
@@ -76,7 +74,6 @@ def test_config_immutability(default_config):
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
RECORDING_VERIFY_SSL=True,
|
||||
AWS_S3_ENDPOINT_URL="https://s3.test.com",
|
||||
AWS_S3_ACCESS_KEY_ID="test_key",
|
||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||
|
||||
@@ -6,10 +6,9 @@ Test worker service classes.
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from core.recording.worker.exceptions import WorkerResponseError
|
||||
from core.recording.worker.factories import WorkerServiceConfig
|
||||
from core.recording.worker.services import (
|
||||
AudioCompositeEgressService,
|
||||
@@ -25,11 +24,10 @@ def config():
|
||||
return WorkerServiceConfig(
|
||||
output_folder="/test/output",
|
||||
server_configurations={
|
||||
"host": "test.livekit.io",
|
||||
"url": "test.livekit.io",
|
||||
"api_key": "test_key",
|
||||
"api_secret": "test_secret",
|
||||
},
|
||||
verify_ssl=True,
|
||||
bucket_args={
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
@@ -127,58 +125,6 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
|
||||
assert result.endswith(f"{filename}.{extension}")
|
||||
|
||||
|
||||
def test_base_egress_handle_request_success(
|
||||
config, service, mock_client_session, mock_egress_service, mock_tcp_connector
|
||||
):
|
||||
"""Test successful request handling"""
|
||||
# Setup mock response
|
||||
mock_response = Mock()
|
||||
mock_method = AsyncMock(return_value=mock_response)
|
||||
mock_egress_instance = Mock()
|
||||
mock_egress_instance.test_method = mock_method
|
||||
mock_egress_service.return_value = mock_egress_instance
|
||||
|
||||
# Create test request
|
||||
test_request = Mock()
|
||||
|
||||
response = service._handle_request(test_request, "test_method")
|
||||
|
||||
mock_client_session.assert_called_once_with(
|
||||
connector=mock_tcp_connector.return_value
|
||||
)
|
||||
|
||||
# Verify EgressService initialization
|
||||
mock_egress_service.assert_called_once_with(
|
||||
mock_client_session.return_value.__aenter__.return_value,
|
||||
**service._config.server_configurations,
|
||||
)
|
||||
|
||||
# Verify method call and response
|
||||
mock_method.assert_called_once_with(test_request)
|
||||
assert response == mock_response
|
||||
|
||||
|
||||
def test_base_egress_handle_request_connection_error(service, mock_egress_service):
|
||||
"""Test handling of connection errors"""
|
||||
# Setup mock error
|
||||
mock_method = AsyncMock(
|
||||
side_effect=livekit_api.TwirpError(msg="Connection failed", code=500)
|
||||
)
|
||||
mock_egress_instance = Mock()
|
||||
mock_egress_instance.test_method = mock_method
|
||||
mock_egress_service.return_value = mock_egress_instance
|
||||
|
||||
# Create test request
|
||||
test_request = Mock()
|
||||
|
||||
# Verify error handling
|
||||
with pytest.raises(WorkerConnectionError) as exc:
|
||||
service._handle_request(test_request, "test_method")
|
||||
|
||||
assert "LiveKit client connection error" in str(exc.value)
|
||||
assert "Connection failed" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response_status,expected_result",
|
||||
[
|
||||
@@ -224,43 +170,6 @@ def test_base_egress_start_not_implemented(service):
|
||||
assert "Subclass must implement this method" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verify_ssl", [True, False])
|
||||
def test_base_egress_ssl_verification_config(verify_ssl):
|
||||
"""Test SSL verification configuration"""
|
||||
config = WorkerServiceConfig(
|
||||
output_folder="/test/output",
|
||||
server_configurations={
|
||||
"host": "test.livekit.io",
|
||||
"api_key": "test_key",
|
||||
"api_secret": "test_secret",
|
||||
},
|
||||
verify_ssl=verify_ssl,
|
||||
bucket_args={
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
"secret": "test_secret",
|
||||
"region": "test-region",
|
||||
"bucket": "test-bucket",
|
||||
"force_path_style": True,
|
||||
},
|
||||
)
|
||||
|
||||
service = BaseEgressService(config)
|
||||
|
||||
# Mock ClientSession to capture connector configuration
|
||||
with patch("aiohttp.ClientSession") as mock_session:
|
||||
mock_session.return_value.__aenter__ = AsyncMock()
|
||||
mock_session.return_value.__aexit__ = AsyncMock()
|
||||
|
||||
# Trigger request to verify connector configuration
|
||||
service._handle_request(Mock(), "test_method")
|
||||
|
||||
# Verify SSL configuration
|
||||
connector = mock_session.call_args[1]["connector"]
|
||||
assert isinstance(connector, aiohttp.TCPConnector)
|
||||
assert connector._ssl == verify_ssl
|
||||
|
||||
|
||||
def test_video_composite_egress_hrid(video_service):
|
||||
"""Test HRID is correct"""
|
||||
assert video_service.hrid == "video-recording-composite-livekit-egress"
|
||||
|
||||
@@ -338,22 +338,20 @@ def test_api_rooms_retrieve_authenticated():
|
||||
)
|
||||
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, settings):
|
||||
"""
|
||||
Users who are members of a room should be allowed to see related users.
|
||||
Users who are members of a room should not be allowed to see related users.
|
||||
"""
|
||||
settings.TIME_ZONE = "UTC"
|
||||
user = UserFactory()
|
||||
other_user = UserFactory()
|
||||
|
||||
room = RoomFactory()
|
||||
user_access = UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
other_user_access = UserResourceAccessFactory(
|
||||
resource=room, user=other_user, role="member"
|
||||
)
|
||||
UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
UserResourceAccessFactory(resource=room, user=other_user, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
)
|
||||
@@ -361,37 +359,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
assert response.status_code == 200
|
||||
content_dict = response.json()
|
||||
|
||||
assert sorted(content_dict.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": {
|
||||
"id": str(user_access.user.id),
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
"short_name": user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": user_access.user.language,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
},
|
||||
{
|
||||
"id": str(other_user_access.id),
|
||||
"user": {
|
||||
"id": str(other_user_access.user.id),
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
"short_name": other_user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": other_user_access.user.language,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert "accesses" not in content_dict
|
||||
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
|
||||
@@ -776,9 +776,10 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
|
||||
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
@@ -794,15 +795,11 @@ def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Act
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Assert
|
||||
# Verify the API was initialized with correct configuration
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
@@ -813,8 +810,8 @@ def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_success(mock_livekit_api, lobby_service):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
|
||||
"""Test successful participant notification."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
@@ -830,14 +827,11 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Call the function
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
@@ -855,15 +849,15 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_error(mock_livekit_api, lobby_service):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
|
||||
"""Test participant notification with API error."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="test error", code=123)
|
||||
side_effect=TwirpError(msg="test error", code=123, status=123)
|
||||
)
|
||||
|
||||
class MockResponse:
|
||||
@@ -874,7 +868,7 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Call the function and expect an exception
|
||||
with pytest.raises(
|
||||
@@ -882,9 +876,6 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
|
||||
):
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import brevo_python
|
||||
import pytest
|
||||
import urllib3
|
||||
|
||||
from core.services.marketing import (
|
||||
BrevoMarketingService,
|
||||
@@ -133,6 +134,34 @@ def test_create_contact_api_error(mock_contact_api):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_timeout_error(mock_contact_api):
|
||||
"""Test contact creation timeout error handling."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
mock_api.create_contact.side_effect = urllib3.exceptions.ReadTimeoutError(
|
||||
pool=mock.Mock(),
|
||||
url="https://api.brevo.com/v3/endpoint",
|
||||
message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.",
|
||||
)
|
||||
|
||||
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clear_marketing_cache():
|
||||
"""Clear marketing service cache between tests."""
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
Unit tests for the Room model
|
||||
"""
|
||||
|
||||
# pylint: disable=W0613
|
||||
|
||||
import secrets
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
@@ -175,3 +181,169 @@ def test_models_rooms_is_public_property():
|
||||
# Test non-public room
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
assert private_room.is_public is False
|
||||
|
||||
|
||||
@mock.patch.object(Room, "generate_unique_pin_code")
|
||||
def test_telephony_disabled_skips_pin_generation(
|
||||
mock_generate_unique_pin_code, settings
|
||||
):
|
||||
"""Telephony disabled should not generate pin codes."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
mock_generate_unique_pin_code.assert_not_called()
|
||||
assert room.pin_code is None
|
||||
|
||||
|
||||
def test_default_and_custom_pin_length(settings):
|
||||
"""Pin codes should be created with correct configured length."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
# Assert default value is 10 for collision reasons
|
||||
assert len(room.pin_code) == 10
|
||||
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
# Assert custom size
|
||||
assert len(room.pin_code) == 5
|
||||
|
||||
|
||||
def test_room_updates_preserve_pin_code(settings):
|
||||
"""Room updates should preserve existing pin code."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
# Store the original pin code to compare after updates
|
||||
previous_pin_code = room.pin_code
|
||||
|
||||
# If this method is called, it would indicate the pin is being regenerated unnecessarily
|
||||
with mock.patch.object(
|
||||
Room, "generate_unique_pin_code"
|
||||
) as mock_generate_unique_pin_code:
|
||||
# Explicitly call save to persist the changes to the room
|
||||
room.slug = "aaa-aaaa-aaa"
|
||||
room.save()
|
||||
assert room.pin_code == previous_pin_code
|
||||
mock_generate_unique_pin_code.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_telephony_enabled", [True, False])
|
||||
def test_manual_pin_code_updates(is_telephony_enabled, settings):
|
||||
"""Manual pin code changes should persist regardless of telephony setting."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = is_telephony_enabled
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
|
||||
|
||||
room = RoomFactory()
|
||||
assert room.pin_code != "12345"
|
||||
|
||||
room.pin_code = "12345"
|
||||
room.save()
|
||||
|
||||
assert room.pin_code == "12345"
|
||||
|
||||
# No data validation when manual updates are made
|
||||
room.pin_code = "123"
|
||||
room.save()
|
||||
|
||||
assert room.pin_code == "123"
|
||||
|
||||
|
||||
def test_pin_code_uniqueness(settings):
|
||||
"""Duplicate pin codes should raise validation error."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
|
||||
|
||||
RoomFactory(pin_code="12345")
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
RoomFactory(pin_code="12345")
|
||||
|
||||
assert "Room with this Room PIN code already exists." in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_length", [0, 1, 2, 3])
|
||||
def test_pin_code_minimum_length(invalid_length, settings):
|
||||
"""Pin codes should enforce minimum length for security."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 4
|
||||
|
||||
# Assert no exception is raised with a valid length
|
||||
RoomFactory()
|
||||
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = invalid_length
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
RoomFactory()
|
||||
|
||||
assert "PIN code length must be at least 4 digits for minimal security" in str(
|
||||
excinfo.value
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(Logger, "warning")
|
||||
@mock.patch.object(secrets, "randbelow", return_value=12345)
|
||||
def test_pin_generation_max_retries(mock_randbelow, mock_logger, settings):
|
||||
"""Pin generation should give up after max retries."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
|
||||
|
||||
RoomFactory(pin_code="12345")
|
||||
|
||||
# Assert default max retries is low, 5
|
||||
room1 = RoomFactory()
|
||||
assert mock_randbelow.call_count == 5
|
||||
assert room1.pin_code is None
|
||||
|
||||
mock_logger.assert_called_once_with(
|
||||
"Failed to generate unique PIN code of length %s after %s attempts", 5, 5
|
||||
)
|
||||
|
||||
mock_logger.reset_mock()
|
||||
mock_randbelow.reset_mock()
|
||||
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES = 3
|
||||
|
||||
room2 = RoomFactory()
|
||||
assert mock_randbelow.call_count == 3
|
||||
assert room2.pin_code is None
|
||||
|
||||
mock_logger.assert_called_once_with(
|
||||
"Failed to generate unique PIN code of length %s after %s attempts", 5, 3
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(secrets, "randbelow", return_value=12345)
|
||||
def test_pin_code_zero_padding(mock_randbelow, settings):
|
||||
"""Pin codes should be zero-padded to meet required length."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
|
||||
|
||||
room = RoomFactory()
|
||||
assert room.pin_code == "0000012345"
|
||||
|
||||
|
||||
@mock.patch.object(secrets, "randbelow", return_value=12345)
|
||||
def test_pin_generation_upper_bound(mock_randbelow, settings):
|
||||
"""Random number generator should use correct upper bound based on pin length."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
room.generate_unique_pin_code(length=5)
|
||||
|
||||
# Assert called with the right exclusive upper bound, 10^5
|
||||
mock_randbelow.assert_called_with(100000)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Test utils functions
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from core.utils import create_livekit_client
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@mock.patch("core.utils.LiveKitAPI")
|
||||
def test_create_livekit_client_ssl_enabled(
|
||||
mock_livekit_api, mock_get_running_loop, settings
|
||||
):
|
||||
"""Test LiveKitAPI client creation with SSL verification enabled."""
|
||||
mock_get_running_loop.return_value = mock.MagicMock()
|
||||
settings.LIVEKIT_VERIFY_SSL = True
|
||||
|
||||
create_livekit_client()
|
||||
|
||||
mock_livekit_api.assert_called_once_with(
|
||||
**settings.LIVEKIT_CONFIGURATION, session=None
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.aiohttp.ClientSession")
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@mock.patch("core.utils.LiveKitAPI")
|
||||
def test_create_livekit_client_ssl_disabled(
|
||||
mock_livekit_api, mock_get_running_loop, mock_client_session, settings
|
||||
):
|
||||
"""Test LiveKitAPI client creation with SSL verification disabled."""
|
||||
mock_get_running_loop.return_value = mock.MagicMock()
|
||||
mock_session_instance = mock.MagicMock()
|
||||
mock_client_session.return_value = mock_session_instance
|
||||
settings.LIVEKIT_VERIFY_SSL = False
|
||||
|
||||
create_livekit_client()
|
||||
|
||||
mock_livekit_api.assert_called_once_with(
|
||||
**settings.LIVEKIT_CONFIGURATION, session=mock_session_instance
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@mock.patch("core.utils.LiveKitAPI")
|
||||
def test_create_livekit_client_custom_configuration(
|
||||
mock_livekit_api, mock_get_running_loop, settings
|
||||
):
|
||||
"""Test LiveKitAPI client creation with custom configuration."""
|
||||
settings.LIVEKIT_VERIFY_SSL = True
|
||||
|
||||
mock_get_running_loop.return_value = mock.MagicMock()
|
||||
custom_configuration = {
|
||||
"api_key": "mock_key",
|
||||
"api_secret": "mock_secret",
|
||||
"url": "http://mock-url.com",
|
||||
}
|
||||
|
||||
create_livekit_client(custom_configuration)
|
||||
|
||||
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
|
||||
@@ -3,10 +3,10 @@
|
||||
from django.conf import settings
|
||||
from django.urls import include, path
|
||||
|
||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.authentication.urls import urlpatterns as oidc_urls
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
|
||||
@@ -13,8 +13,9 @@ from uuid import uuid4
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import aiohttp
|
||||
import botocore
|
||||
from livekit.api import AccessToken, VideoGrants
|
||||
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
|
||||
|
||||
|
||||
def generate_color(identity: str) -> str:
|
||||
@@ -142,3 +143,18 @@ def generate_s3_authorization_headers(key):
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
|
||||
|
||||
def create_livekit_client(custom_configuration=None):
|
||||
"""Create and return a configured LiveKit API client."""
|
||||
|
||||
custom_session = None
|
||||
|
||||
if not settings.LIVEKIT_VERIFY_SSL:
|
||||
connector = aiohttp.TCPConnector(ssl=False)
|
||||
custom_session = aiohttp.ClientSession(connector=connector)
|
||||
|
||||
# Use default configuration if none provided
|
||||
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
|
||||
|
||||
return LiveKitAPI(session=custom_session, **configuration)
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Informationen"
|
||||
|
||||
#: core/admin.py:39
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: core/admin.py:51
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: core/admin.py:143
|
||||
msgid "No owner"
|
||||
msgstr "Kein Eigentümer"
|
||||
|
||||
#: core/admin.py:146
|
||||
msgid "Multiple owners"
|
||||
msgstr "Mehrere Eigentümer"
|
||||
|
||||
#: core/api/serializers.py:63
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||
"hinzuzufügen."
|
||||
|
||||
#: core/models.py:31
|
||||
msgid "Member"
|
||||
msgstr "Mitglied"
|
||||
|
||||
#: core/models.py:32
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:33
|
||||
msgid "Owner"
|
||||
msgstr "Eigentümer"
|
||||
|
||||
#: core/models.py:49
|
||||
msgid "Initiated"
|
||||
msgstr "Gestartet"
|
||||
|
||||
#: core/models.py:50
|
||||
msgid "Active"
|
||||
msgstr "Aktiv"
|
||||
|
||||
#: core/models.py:51
|
||||
msgid "Stopped"
|
||||
msgstr "Beendet"
|
||||
|
||||
#: core/models.py:52
|
||||
msgid "Saved"
|
||||
msgstr "Gespeichert"
|
||||
|
||||
#: core/models.py:53
|
||||
msgid "Aborted"
|
||||
msgstr "Abgebrochen"
|
||||
|
||||
#: core/models.py:54
|
||||
msgid "Failed to Start"
|
||||
msgstr "Start fehlgeschlagen"
|
||||
|
||||
#: core/models.py:55
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stopp fehlgeschlagen"
|
||||
|
||||
#: core/models.py:56
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Benachrichtigung erfolgreich"
|
||||
|
||||
#: core/models.py:83
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||
|
||||
#: core/models.py:84
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSKRIPT"
|
||||
|
||||
#: core/models.py:90
|
||||
msgid "Public Access"
|
||||
msgstr "Öffentlicher Zugriff"
|
||||
|
||||
#: core/models.py:91
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrauenswürdiger Zugriff"
|
||||
|
||||
#: core/models.py:92
|
||||
msgid "Restricted Access"
|
||||
msgstr "Eingeschränkter Zugriff"
|
||||
|
||||
#: core/models.py:104
|
||||
msgid "id"
|
||||
msgstr "ID"
|
||||
|
||||
#: core/models.py:105
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||
|
||||
#: core/models.py:111
|
||||
msgid "created on"
|
||||
msgstr "erstellt am"
|
||||
|
||||
#: core/models.py:112
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||
|
||||
#: core/models.py:117
|
||||
msgid "updated on"
|
||||
msgstr "aktualisiert am"
|
||||
|
||||
#: core/models.py:118
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||
|
||||
#: core/models.py:138
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
||||
"und die Zeichen @/./+/-/_ enthalten."
|
||||
|
||||
#: core/models.py:144
|
||||
msgid "sub"
|
||||
msgstr "Sub"
|
||||
|
||||
#: core/models.py:146
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
|
||||
"erlaubt."
|
||||
|
||||
#: core/models.py:154
|
||||
msgid "identity email address"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:159
|
||||
msgid "admin email address"
|
||||
msgstr "Administrator-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:161
|
||||
msgid "full name"
|
||||
msgstr "Vollständiger Name"
|
||||
|
||||
#: core/models.py:163
|
||||
msgid "short name"
|
||||
msgstr "Kurzname"
|
||||
|
||||
#: core/models.py:169
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: core/models.py:170
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
||||
|
||||
#: core/models.py:176
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
||||
|
||||
#: core/models.py:179
|
||||
msgid "device"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: core/models.py:181
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
||||
|
||||
#: core/models.py:184
|
||||
msgid "staff status"
|
||||
msgstr "Mitarbeiterstatus"
|
||||
|
||||
#: core/models.py:186
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||
|
||||
#: core/models.py:189
|
||||
msgid "active"
|
||||
msgstr "aktiv"
|
||||
|
||||
#: core/models.py:192
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||
"anstelle des Löschens des Kontos."
|
||||
|
||||
#: core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:265
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:266
|
||||
msgid "Resources"
|
||||
msgstr "Ressourcen"
|
||||
|
||||
#: core/models.py:320
|
||||
msgid "Resource access"
|
||||
msgstr "Ressourcenzugriff"
|
||||
|
||||
#: core/models.py:321
|
||||
msgid "Resource accesses"
|
||||
msgstr "Ressourcenzugriffe"
|
||||
|
||||
#: core/models.py:327
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||
"bereits."
|
||||
|
||||
#: core/models.py:383
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-Raumkonfiguration"
|
||||
|
||||
#: core/models.py:384
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||
|
||||
#: core/models.py:391
|
||||
msgid "Room PIN code"
|
||||
msgstr "PIN-Code für den Raum"
|
||||
|
||||
#: core/models.py:392
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr "Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||
|
||||
#: core/models.py:398 core/models.py:552
|
||||
msgid "Room"
|
||||
msgstr "Raum"
|
||||
|
||||
#: core/models.py:399
|
||||
msgid "Rooms"
|
||||
msgstr "Räume"
|
||||
|
||||
#: core/models.py:563
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker-ID"
|
||||
|
||||
#: core/models.py:565
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
msgstr ""
|
||||
"Geben Sie eine ID für die Aufzeichnung des Workers ein. Diese ID bleibt "
|
||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||
"ermöglicht."
|
||||
|
||||
#: core/models.py:573
|
||||
msgid "Recording mode"
|
||||
msgstr "Aufzeichnungsmodus"
|
||||
|
||||
#: core/models.py:574
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||
|
||||
#: core/models.py:580
|
||||
msgid "Recording"
|
||||
msgstr "Aufzeichnung"
|
||||
|
||||
#: core/models.py:581
|
||||
msgid "Recordings"
|
||||
msgstr "Aufzeichnungen"
|
||||
|
||||
#: core/models.py:689
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:690
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:696
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:702
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:708
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Ihre Aufzeichnung ist bereit"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Logo-E-Mail"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "lädt Sie zu einem laufenden Videoanruf ein"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "AM ANRUF TEILNEHMEN"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
msgstr ""
|
||||
"Wenn Sie den Button nicht anklicken können, kopieren Sie die URL und fügen "
|
||||
"Sie sie in Ihren Browser ein, um am Anruf teilzunehmen."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Tipps für ein besseres Erlebnis:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:240
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:22
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr "Ihre Aufzeichnung ist fertig!"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
msgstr ""
|
||||
" Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um "
|
||||
"%(recording_time)s steht nun zum Herunterladen bereit. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:205
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
msgstr ""
|
||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||
"an unser Support-Team unter %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:162
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: meet/settings.py:163
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: meet/settings.py:164
|
||||
msgid "Dutch"
|
||||
msgstr "Niederländisch"
|
||||
|
||||
#: meet/settings.py:165
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
|
||||
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,111 +29,107 @@ msgstr "Permissions"
|
||||
msgid "Important dates"
|
||||
msgstr "Important dates"
|
||||
|
||||
#: core/admin.py:143
|
||||
msgid "No owner"
|
||||
msgstr "No owner"
|
||||
|
||||
#: core/admin.py:146
|
||||
msgid "Multiple owners"
|
||||
msgstr "Multiple owners"
|
||||
|
||||
#: core/api/serializers.py:63
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||
|
||||
#: core/authentication/backends.py:77
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr "User info contained no recognizable user identification"
|
||||
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr "User account is disabled"
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr "Multiple user accounts share a common email."
|
||||
|
||||
#: core/models.py:30
|
||||
#: core/models.py:31
|
||||
msgid "Member"
|
||||
msgstr "Member"
|
||||
|
||||
#: core/models.py:31
|
||||
#: core/models.py:32
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:32
|
||||
#: core/models.py:33
|
||||
msgid "Owner"
|
||||
msgstr "Owner"
|
||||
|
||||
#: core/models.py:48
|
||||
#: core/models.py:49
|
||||
msgid "Initiated"
|
||||
msgstr "Initiated"
|
||||
|
||||
#: core/models.py:49
|
||||
#: core/models.py:50
|
||||
msgid "Active"
|
||||
msgstr "Active"
|
||||
|
||||
#: core/models.py:50
|
||||
#: core/models.py:51
|
||||
msgid "Stopped"
|
||||
msgstr "Stopped"
|
||||
|
||||
#: core/models.py:51
|
||||
#: core/models.py:52
|
||||
msgid "Saved"
|
||||
msgstr "Saved"
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:53
|
||||
msgid "Aborted"
|
||||
msgstr "Aborted"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:54
|
||||
msgid "Failed to Start"
|
||||
msgstr "Failed to Start"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:55
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Failed to Stop"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:56
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification succeeded"
|
||||
|
||||
#: core/models.py:82
|
||||
#: core/models.py:83
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCREEN_RECORDING"
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:84
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:90
|
||||
msgid "Public Access"
|
||||
msgstr "Public Access"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:91
|
||||
msgid "Trusted Access"
|
||||
msgstr "Trusted Access"
|
||||
|
||||
#: core/models.py:91
|
||||
#: core/models.py:92
|
||||
msgid "Restricted Access"
|
||||
msgstr "Restricted Access"
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:104
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:105
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primary key for the record as UUID"
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:111
|
||||
msgid "created on"
|
||||
msgstr "created on"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:112
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date and time at which a record was created"
|
||||
|
||||
#: core/models.py:116
|
||||
#: core/models.py:117
|
||||
msgid "updated on"
|
||||
msgstr "updated on"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:118
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "date and time at which a record was last updated"
|
||||
|
||||
#: core/models.py:137
|
||||
#: core/models.py:138
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -141,11 +137,11 @@ msgstr ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
|
||||
#: core/models.py:143
|
||||
#: core/models.py:144
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:145
|
||||
#: core/models.py:146
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
@@ -153,55 +149,55 @@ msgstr ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
|
||||
#: core/models.py:153
|
||||
#: core/models.py:154
|
||||
msgid "identity email address"
|
||||
msgstr "identity email address"
|
||||
|
||||
#: core/models.py:158
|
||||
#: core/models.py:159
|
||||
msgid "admin email address"
|
||||
msgstr "admin email address"
|
||||
|
||||
#: core/models.py:160
|
||||
#: core/models.py:161
|
||||
msgid "full name"
|
||||
msgstr "full name"
|
||||
|
||||
#: core/models.py:162
|
||||
#: core/models.py:163
|
||||
msgid "short name"
|
||||
msgstr "short name"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:169
|
||||
msgid "language"
|
||||
msgstr "language"
|
||||
|
||||
#: core/models.py:169
|
||||
#: core/models.py:170
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "The language in which the user wants to see the interface."
|
||||
|
||||
#: core/models.py:175
|
||||
#: core/models.py:176
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "The timezone in which the user wants to see times."
|
||||
|
||||
#: core/models.py:178
|
||||
#: core/models.py:179
|
||||
msgid "device"
|
||||
msgstr "device"
|
||||
|
||||
#: core/models.py:180
|
||||
#: core/models.py:181
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Whether the user is a device or a real user."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:184
|
||||
msgid "staff status"
|
||||
msgstr "staff status"
|
||||
|
||||
#: core/models.py:185
|
||||
#: core/models.py:186
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Whether the user can log into this admin site."
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:189
|
||||
msgid "active"
|
||||
msgstr "active"
|
||||
|
||||
#: core/models.py:191
|
||||
#: core/models.py:192
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -209,55 +205,63 @@ msgstr ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
|
||||
#: core/models.py:204
|
||||
#: core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "user"
|
||||
|
||||
#: core/models.py:205
|
||||
#: core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "users"
|
||||
|
||||
#: core/models.py:264
|
||||
#: core/models.py:265
|
||||
msgid "Resource"
|
||||
msgstr "Resource"
|
||||
|
||||
#: core/models.py:265
|
||||
#: core/models.py:266
|
||||
msgid "Resources"
|
||||
msgstr "Resources"
|
||||
|
||||
#: core/models.py:319
|
||||
#: core/models.py:320
|
||||
msgid "Resource access"
|
||||
msgstr "Resource access"
|
||||
|
||||
#: core/models.py:320
|
||||
#: core/models.py:321
|
||||
msgid "Resource accesses"
|
||||
msgstr "Resource accesses"
|
||||
|
||||
#: core/models.py:326
|
||||
#: core/models.py:327
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Resource access with this User and Resource already exists."
|
||||
|
||||
#: core/models.py:382
|
||||
#: core/models.py:383
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio room configuration"
|
||||
|
||||
#: core/models.py:383
|
||||
#: core/models.py:384
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Values for Visio parameters to configure the room."
|
||||
|
||||
#: core/models.py:389 core/models.py:509
|
||||
#: core/models.py:391
|
||||
msgid "Room PIN code"
|
||||
msgstr "Room PIN code"
|
||||
|
||||
#: core/models.py:392
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
||||
|
||||
#: core/models.py:398 core/models.py:552
|
||||
msgid "Room"
|
||||
msgstr "Room"
|
||||
|
||||
#: core/models.py:390
|
||||
#: core/models.py:399
|
||||
msgid "Rooms"
|
||||
msgstr "Rooms"
|
||||
|
||||
#: core/models.py:520
|
||||
#: core/models.py:563
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:522
|
||||
#: core/models.py:565
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -265,58 +269,42 @@ msgstr ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
|
||||
#: core/models.py:530
|
||||
#: core/models.py:573
|
||||
msgid "Recording mode"
|
||||
msgstr "Recording mode"
|
||||
|
||||
#: core/models.py:531
|
||||
#: core/models.py:574
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Defines the mode of recording being called."
|
||||
|
||||
#: core/models.py:537
|
||||
#: core/models.py:580
|
||||
msgid "Recording"
|
||||
msgstr "Recording"
|
||||
|
||||
#: core/models.py:538
|
||||
#: core/models.py:581
|
||||
msgid "Recordings"
|
||||
msgstr "Recordings"
|
||||
|
||||
#: core/models.py:646
|
||||
#: core/models.py:689
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Recording/user relation"
|
||||
|
||||
#: core/models.py:647
|
||||
#: core/models.py:690
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Recording/user relations"
|
||||
|
||||
#: core/models.py:653
|
||||
#: core/models.py:696
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "This user is already in this recording."
|
||||
|
||||
#: core/models.py:659
|
||||
#: core/models.py:702
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "This team is already in this recording."
|
||||
|
||||
#: core/models.py:665
|
||||
#: core/models.py:708
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Either user or team must be set, not both."
|
||||
|
||||
#: core/recording/event/authentication.py:58
|
||||
msgid "Authentication is enabled but token is not configured."
|
||||
msgstr "Authentication is enabled but token is not configured."
|
||||
|
||||
#: core/recording/event/authentication.py:70
|
||||
msgid "Authorization header is required"
|
||||
msgstr "Authorization header is required"
|
||||
|
||||
#: core/recording/event/authentication.py:78
|
||||
msgid "Invalid authorization header."
|
||||
msgstr "Invalid authorization header."
|
||||
|
||||
#: core/recording/event/authentication.py:88
|
||||
msgid "Invalid token"
|
||||
msgstr "Invalid token"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Your recording is ready"
|
||||
@@ -447,3 +435,7 @@ msgstr "French"
|
||||
#: meet/settings.py:164
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
|
||||
#: meet/settings.py:165
|
||||
msgid "German"
|
||||
msgstr "German"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
|
||||
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,129 +29,123 @@ msgstr "Permissions"
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/admin.py:143
|
||||
msgid "No owner"
|
||||
msgstr "Pas de propriétaire"
|
||||
|
||||
#: core/admin.py:146
|
||||
msgid "Multiple owners"
|
||||
msgstr "Plusieurs propriétaires"
|
||||
|
||||
#: core/api/serializers.py:63
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||
"des accès."
|
||||
|
||||
#: core/authentication/backends.py:77
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
"Les informations utilisateur ne contiennent aucune identification "
|
||||
"utilisateur reconnaissable"
|
||||
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr "Le compte utilisateur est désactivé"
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr "Plusieurs comptes utilisateur partagent une adresse e-mail commune."
|
||||
|
||||
#: core/models.py:30
|
||||
#: core/models.py:31
|
||||
msgid "Member"
|
||||
msgstr "Membre"
|
||||
|
||||
#: core/models.py:31
|
||||
#: core/models.py:32
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: core/models.py:32
|
||||
#: core/models.py:33
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:48
|
||||
#: core/models.py:49
|
||||
msgid "Initiated"
|
||||
msgstr "Initié"
|
||||
|
||||
#: core/models.py:49
|
||||
#: core/models.py:50
|
||||
msgid "Active"
|
||||
msgstr "Actif"
|
||||
|
||||
#: core/models.py:50
|
||||
#: core/models.py:51
|
||||
msgid "Stopped"
|
||||
msgstr "Arrêté"
|
||||
|
||||
#: core/models.py:51
|
||||
#: core/models.py:52
|
||||
msgid "Saved"
|
||||
msgstr "Enregistré"
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:53
|
||||
msgid "Aborted"
|
||||
msgstr "Abandonné"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:54
|
||||
msgid "Failed to Start"
|
||||
msgstr "Échec au démarrage"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:55
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Échec à l'arrêt"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:56
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification réussie"
|
||||
|
||||
#: core/models.py:82
|
||||
#: core/models.py:83
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:84
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPTION"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:90
|
||||
msgid "Public Access"
|
||||
msgstr "Accès public"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:91
|
||||
msgid "Trusted Access"
|
||||
msgstr "Accès de confiance"
|
||||
|
||||
#: core/models.py:91
|
||||
#: core/models.py:92
|
||||
msgid "Restricted Access"
|
||||
msgstr "Accès restreint"
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:104
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:105
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:111
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:112
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||
|
||||
#: core/models.py:116
|
||||
#: core/models.py:117
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:118
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||
"fois"
|
||||
|
||||
#: core/models.py:137
|
||||
#: core/models.py:138
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
"Entrez un identifiant valide. Cette valeur ne peut contenir que des lettres, "
|
||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, "
|
||||
"des chiffres et les caractères @/./+/-/_."
|
||||
|
||||
#: core/models.py:143
|
||||
#: core/models.py:144
|
||||
msgid "sub"
|
||||
msgstr "identifiant"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:145
|
||||
#: core/models.py:146
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
@@ -159,55 +153,55 @@ msgstr ""
|
||||
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
|
||||
"+/-/_ uniquement."
|
||||
|
||||
#: core/models.py:153
|
||||
#: core/models.py:154
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: core/models.py:158
|
||||
#: core/models.py:159
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail d'administrateur"
|
||||
|
||||
#: core/models.py:160
|
||||
#: core/models.py:161
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:162
|
||||
#: core/models.py:163
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:169
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: core/models.py:169
|
||||
#: core/models.py:170
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||
|
||||
#: core/models.py:175
|
||||
#: core/models.py:176
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: core/models.py:178
|
||||
#: core/models.py:179
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: core/models.py:180
|
||||
#: core/models.py:181
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:184
|
||||
msgid "staff status"
|
||||
msgstr "statut du personnel"
|
||||
|
||||
#: core/models.py:185
|
||||
#: core/models.py:186
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:189
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: core/models.py:191
|
||||
#: core/models.py:192
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -215,116 +209,108 @@ msgstr ""
|
||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||
"au lieu de supprimer des comptes."
|
||||
|
||||
#: core/models.py:204
|
||||
#: core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: core/models.py:205
|
||||
#: core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: core/models.py:264
|
||||
#: core/models.py:265
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:265
|
||||
#: core/models.py:266
|
||||
msgid "Resources"
|
||||
msgstr "Ressources"
|
||||
|
||||
#: core/models.py:319
|
||||
#: core/models.py:320
|
||||
msgid "Resource access"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:320
|
||||
#: core/models.py:321
|
||||
msgid "Resource accesses"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:326
|
||||
#: core/models.py:327
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
||||
|
||||
#: core/models.py:382
|
||||
#: core/models.py:383
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuration de la salle de visioconférence"
|
||||
|
||||
#: core/models.py:383
|
||||
#: core/models.py:384
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||
|
||||
#: core/models.py:389 core/models.py:509
|
||||
#: core/models.py:391
|
||||
msgid "Room PIN code"
|
||||
msgstr "Code PIN de la salle"
|
||||
|
||||
#: core/models.py:392
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr "Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||
|
||||
#: core/models.py:398 core/models.py:552
|
||||
msgid "Room"
|
||||
msgstr "Salle"
|
||||
|
||||
#: core/models.py:390
|
||||
#: core/models.py:399
|
||||
msgid "Rooms"
|
||||
msgstr "Salles"
|
||||
|
||||
#: core/models.py:520
|
||||
#: core/models.py:563
|
||||
msgid "Worker ID"
|
||||
msgstr "ID du Worker"
|
||||
|
||||
#: core/models.py:522
|
||||
#: core/models.py:565
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
msgstr ""
|
||||
"Entrez un identifiant pour l'enregistrement du travailleur. Cet identifiant "
|
||||
"est conservé même lorsque le travailleur s'arrête, permettant un suivi "
|
||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant "
|
||||
"est conservé même lorsque le Worker s'arrête, permettant un suivi "
|
||||
"facile."
|
||||
|
||||
#: core/models.py:530
|
||||
#: core/models.py:573
|
||||
msgid "Recording mode"
|
||||
msgstr "Mode d'enregistrement"
|
||||
|
||||
#: core/models.py:531
|
||||
#: core/models.py:574
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Définit le mode d'enregistrement appelé."
|
||||
|
||||
#: core/models.py:537
|
||||
#: core/models.py:580
|
||||
msgid "Recording"
|
||||
msgstr "Enregistrement"
|
||||
|
||||
#: core/models.py:538
|
||||
#: core/models.py:581
|
||||
msgid "Recordings"
|
||||
msgstr "Enregistrements"
|
||||
|
||||
#: core/models.py:646
|
||||
#: core/models.py:689
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relation enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:647
|
||||
#: core/models.py:690
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relations enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:653
|
||||
#: core/models.py:696
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:659
|
||||
#: core/models.py:702
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:665
|
||||
#: core/models.py:708
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||
|
||||
#: core/recording/event/authentication.py:58
|
||||
msgid "Authentication is enabled but token is not configured."
|
||||
msgstr "L'authentification est activée mais le jeton n'est pas configuré."
|
||||
|
||||
#: core/recording/event/authentication.py:70
|
||||
msgid "Authorization header is required"
|
||||
msgstr "L'en-tête d'autorisation est requis"
|
||||
|
||||
#: core/recording/event/authentication.py:78
|
||||
msgid "Invalid authorization header."
|
||||
msgstr "En-tête d'autorisation invalide."
|
||||
|
||||
#: core/recording/event/authentication.py:88
|
||||
msgid "Invalid token"
|
||||
msgstr "Jeton invalide"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Votre enregistrement est prêt"
|
||||
@@ -455,3 +441,7 @@ msgstr "Français"
|
||||
#: meet/settings.py:164
|
||||
msgid "Dutch"
|
||||
msgstr "Néerlandais"
|
||||
|
||||
#: meet/settings.py:165
|
||||
msgid "German"
|
||||
msgstr "Allemand"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
|
||||
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,112 +29,108 @@ msgstr "Rechten"
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke datums"
|
||||
|
||||
#: core/admin.py:143
|
||||
msgid "No owner"
|
||||
msgstr "Geen eigenaar"
|
||||
|
||||
#: core/admin.py:146
|
||||
msgid "Multiple owners"
|
||||
msgstr "Meerdere eigenaren"
|
||||
|
||||
#: core/api/serializers.py:63
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||
|
||||
#: core/authentication/backends.py:77
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr "Gebruikersinformatie bevatte geen herkenbare gebruikersidentificatie"
|
||||
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr "Gebruikersaccount is uitgeschakeld"
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr "Meerdere gebruikersaccounts delen een gemeenschappelijk e-mailadres."
|
||||
|
||||
#: core/models.py:30
|
||||
#: core/models.py:31
|
||||
msgid "Member"
|
||||
msgstr "Lid"
|
||||
|
||||
#: core/models.py:31
|
||||
#: core/models.py:32
|
||||
msgid "Administrator"
|
||||
msgstr "Beheerder"
|
||||
|
||||
#: core/models.py:32
|
||||
#: core/models.py:33
|
||||
msgid "Owner"
|
||||
msgstr "Eigenaar"
|
||||
|
||||
#: core/models.py:48
|
||||
#: core/models.py:49
|
||||
msgid "Initiated"
|
||||
msgstr "Gestart"
|
||||
|
||||
#: core/models.py:49
|
||||
#: core/models.py:50
|
||||
msgid "Active"
|
||||
msgstr "Actief"
|
||||
|
||||
#: core/models.py:50
|
||||
#: core/models.py:51
|
||||
msgid "Stopped"
|
||||
msgstr "Gestopt"
|
||||
|
||||
#: core/models.py:51
|
||||
#: core/models.py:52
|
||||
msgid "Saved"
|
||||
msgstr "Opgeslagen"
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:53
|
||||
msgid "Aborted"
|
||||
msgstr "Afgebroken"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:54
|
||||
msgid "Failed to Start"
|
||||
msgstr "Starten mislukt"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:55
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stoppen mislukt"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:56
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notificatie geslaagd"
|
||||
|
||||
#: core/models.py:82
|
||||
#: core/models.py:83
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCHERM_OPNAME"
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:84
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:90
|
||||
msgid "Public Access"
|
||||
msgstr "Openbare toegang"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:91
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrouwde toegang"
|
||||
|
||||
#: core/models.py:91
|
||||
#: core/models.py:92
|
||||
msgid "Restricted Access"
|
||||
msgstr "Beperkte toegang"
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:104
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:105
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor het record als UUID"
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:111
|
||||
msgid "created on"
|
||||
msgstr "aangemaakt op"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:112
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||
|
||||
#: core/models.py:116
|
||||
#: core/models.py:117
|
||||
msgid "updated on"
|
||||
msgstr "bijgewerkt op"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:118
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
||||
|
||||
#: core/models.py:137
|
||||
#: core/models.py:138
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -142,66 +138,66 @@ msgstr ""
|
||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||
"_ tekens bevatten."
|
||||
|
||||
#: core/models.py:143
|
||||
#: core/models.py:144
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:145
|
||||
#: core/models.py:146
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
|
||||
|
||||
#: core/models.py:153
|
||||
#: core/models.py:154
|
||||
msgid "identity email address"
|
||||
msgstr "identiteit e-mailadres"
|
||||
|
||||
#: core/models.py:158
|
||||
#: core/models.py:159
|
||||
msgid "admin email address"
|
||||
msgstr "beheerder e-mailadres"
|
||||
|
||||
#: core/models.py:160
|
||||
#: core/models.py:161
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: core/models.py:162
|
||||
#: core/models.py:163
|
||||
msgid "short name"
|
||||
msgstr "korte naam"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:169
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: core/models.py:169
|
||||
#: core/models.py:170
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||
|
||||
#: core/models.py:175
|
||||
#: core/models.py:176
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||
|
||||
#: core/models.py:178
|
||||
#: core/models.py:179
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: core/models.py:180
|
||||
#: core/models.py:181
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:184
|
||||
msgid "staff status"
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: core/models.py:185
|
||||
#: core/models.py:186
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:189
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: core/models.py:191
|
||||
#: core/models.py:192
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -209,55 +205,63 @@ msgstr ""
|
||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||
"plaats van accounts te verwijderen."
|
||||
|
||||
#: core/models.py:204
|
||||
#: core/models.py:205
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: core/models.py:205
|
||||
#: core/models.py:206
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: core/models.py:264
|
||||
#: core/models.py:265
|
||||
msgid "Resource"
|
||||
msgstr "Bron"
|
||||
|
||||
#: core/models.py:265
|
||||
#: core/models.py:266
|
||||
msgid "Resources"
|
||||
msgstr "Bronnen"
|
||||
|
||||
#: core/models.py:319
|
||||
#: core/models.py:320
|
||||
msgid "Resource access"
|
||||
msgstr "Brontoegang"
|
||||
|
||||
#: core/models.py:320
|
||||
#: core/models.py:321
|
||||
msgid "Resource accesses"
|
||||
msgstr "Brontoegangsrechten"
|
||||
|
||||
#: core/models.py:326
|
||||
#: core/models.py:327
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||
|
||||
#: core/models.py:382
|
||||
#: core/models.py:383
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-ruimteconfiguratie"
|
||||
|
||||
#: core/models.py:383
|
||||
#: core/models.py:384
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||
|
||||
#: core/models.py:389 core/models.py:509
|
||||
#: core/models.py:391
|
||||
msgid "Room PIN code"
|
||||
msgstr "Pincode van de kamer"
|
||||
|
||||
#: core/models.py:392
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr "Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
||||
|
||||
#: core/models.py:398 core/models.py:552
|
||||
msgid "Room"
|
||||
msgstr "Ruimte"
|
||||
|
||||
#: core/models.py:390
|
||||
#: core/models.py:399
|
||||
msgid "Rooms"
|
||||
msgstr "Ruimtes"
|
||||
|
||||
#: core/models.py:520
|
||||
#: core/models.py:563
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:522
|
||||
#: core/models.py:565
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -265,58 +269,42 @@ msgstr ""
|
||||
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
||||
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
||||
|
||||
#: core/models.py:530
|
||||
#: core/models.py:573
|
||||
msgid "Recording mode"
|
||||
msgstr "Opnamemodus"
|
||||
|
||||
#: core/models.py:531
|
||||
#: core/models.py:574
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||
|
||||
#: core/models.py:537
|
||||
#: core/models.py:580
|
||||
msgid "Recording"
|
||||
msgstr "Opname"
|
||||
|
||||
#: core/models.py:538
|
||||
#: core/models.py:581
|
||||
msgid "Recordings"
|
||||
msgstr "Opnames"
|
||||
|
||||
#: core/models.py:646
|
||||
#: core/models.py:689
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Opname/gebruiker-relatie"
|
||||
|
||||
#: core/models.py:647
|
||||
#: core/models.py:690
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Opname/gebruiker-relaties"
|
||||
|
||||
#: core/models.py:653
|
||||
#: core/models.py:696
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Deze gebruiker is al in deze opname."
|
||||
|
||||
#: core/models.py:659
|
||||
#: core/models.py:702
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dit team is al in deze opname."
|
||||
|
||||
#: core/models.py:665
|
||||
#: core/models.py:708
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||
|
||||
#: core/recording/event/authentication.py:58
|
||||
msgid "Authentication is enabled but token is not configured."
|
||||
msgstr "Authenticatie is ingeschakeld maar token is niet geconfigureerd."
|
||||
|
||||
#: core/recording/event/authentication.py:70
|
||||
msgid "Authorization header is required"
|
||||
msgstr "Autorisatie-header is vereist"
|
||||
|
||||
#: core/recording/event/authentication.py:78
|
||||
msgid "Invalid authorization header."
|
||||
msgstr "Ongeldige autorisatie-header."
|
||||
|
||||
#: core/recording/event/authentication.py:88
|
||||
msgid "Invalid token"
|
||||
msgstr "Ongeldig token"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Je opname is klaar"
|
||||
@@ -447,3 +435,7 @@ msgstr "Frans"
|
||||
#: meet/settings.py:164
|
||||
msgid "Dutch"
|
||||
msgstr "Nederlands"
|
||||
|
||||
#: meet/settings.py:165
|
||||
msgid "German"
|
||||
msgstr "Duits"
|
||||
|
||||
@@ -162,6 +162,7 @@ class Base(Configuration):
|
||||
("en-us", _("English")),
|
||||
("fr-fr", _("French")),
|
||||
("nl-nl", _("Dutch")),
|
||||
("de-de", _("German")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -263,6 +264,9 @@ class Base(Configuration):
|
||||
"rest_framework.parsers.JSONParser",
|
||||
"nested_multipart_parser.drf.DrfNestedParser",
|
||||
],
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
],
|
||||
"EXCEPTION_HANDLER": "core.api.exception_handler",
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 20,
|
||||
@@ -310,7 +314,10 @@ class Base(Configuration):
|
||||
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
|
||||
),
|
||||
"is_silent_login_enabled": values.BooleanValue(
|
||||
True, environ_name="FRONTEND_IS_SILENT_LOGING_ENABLED", environ_prefix=None
|
||||
True, environ_name="FRONTEND_IS_SILENT_LOGIN_ENABLED", environ_prefix=None
|
||||
),
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
}
|
||||
|
||||
@@ -353,11 +360,13 @@ class Base(Configuration):
|
||||
# Session
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 12
|
||||
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
|
||||
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
|
||||
)
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
|
||||
)
|
||||
@@ -391,6 +400,9 @@ class Base(Configuration):
|
||||
OIDC_OP_USER_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
|
||||
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
@@ -437,6 +449,11 @@ class Base(Configuration):
|
||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_USERINFO_ESSENTIAL_CLAIMS = values.ListValue(
|
||||
default=[],
|
||||
environ_name="OIDC_USERINFO_ESSENTIAL_CLAIMS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Video conference configuration
|
||||
LIVEKIT_CONFIGURATION = {
|
||||
@@ -446,6 +463,9 @@ class Base(Configuration):
|
||||
),
|
||||
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
||||
}
|
||||
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
RESOURCE_DEFAULT_ACCESS_LEVEL = values.Value(
|
||||
"public", environ_name="RESOURCE_DEFAULT_ACCESS_LEVEL", environ_prefix=None
|
||||
)
|
||||
@@ -460,9 +480,6 @@ class Base(Configuration):
|
||||
RECORDING_OUTPUT_FOLDER = values.Value(
|
||||
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
|
||||
)
|
||||
RECORDING_VERIFY_SSL = values.BooleanValue(
|
||||
True, environ_name="RECORDING_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
RECORDING_WORKER_CLASSES = values.DictValue(
|
||||
{
|
||||
"screen_recording": "core.recording.worker.services.VideoCompositeEgressService",
|
||||
@@ -558,6 +575,23 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# SIP Telephony
|
||||
ROOM_TELEPHONY_ENABLED = values.BooleanValue(
|
||||
False,
|
||||
environ_name="ROOM_TELEPHONY_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ROOM_TELEPHONY_PIN_LENGTH = values.PositiveIntegerValue(
|
||||
10,
|
||||
environ_name="ROOM_TELEPHONY_PIN_LENGTH",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ROOM_TELEPHONY_PIN_MAX_RETRIES = values.PositiveIntegerValue(
|
||||
5,
|
||||
environ_name="ROOM_TELEPHONY_PIN_MAX_RETRIES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -604,10 +638,7 @@ class Base(Configuration):
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
)
|
||||
|
||||
# Add the application name to the Sentry scope
|
||||
scope = sentry_sdk.get_global_scope()
|
||||
scope.set_tag("application", "backend")
|
||||
sentry_sdk.set_tag("application", "backend")
|
||||
|
||||
# Ignore the logs added by the DockerflowMiddleware
|
||||
ignore_logger("request.summary")
|
||||
@@ -771,6 +802,14 @@ class Staging(Production):
|
||||
"""
|
||||
|
||||
|
||||
class Sandbox(Production):
|
||||
"""
|
||||
Sandbox environment settings
|
||||
|
||||
nota bene: it should inherit from the Production environment.
|
||||
"""
|
||||
|
||||
|
||||
class PreProduction(Production):
|
||||
"""
|
||||
Pre-production environment settings
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.19"
|
||||
version = "0.1.23"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -32,12 +32,13 @@ dependencies = [
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-lasuite==0.0.7",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.5",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.8",
|
||||
"django==5.1.9",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
@@ -55,7 +56,7 @@ dependencies = [
|
||||
"sentry-sdk==2.24.1",
|
||||
"whitenoise==6.9.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.8.2",
|
||||
"livekit-api==1.0.2",
|
||||
"aiohttp==3.11.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
server_tokens off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.23",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -14,47 +14,47 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/track-processors": "0.3.3",
|
||||
"@pandacss/preset-panda": "0.53.3",
|
||||
"@react-aria/toast": "3.0.1",
|
||||
"@livekit/components-styles": "1.1.5",
|
||||
"@livekit/track-processors": "0.5.6",
|
||||
"@pandacss/preset-panda": "0.53.6",
|
||||
"@react-aria/toast": "3.0.2",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.69.0",
|
||||
"@tanstack/react-query": "5.76.0",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.3",
|
||||
"i18next": "24.2.3",
|
||||
"i18next-browser-languagedetector": "8.0.4",
|
||||
"i18next": "25.1.2",
|
||||
"i18next-browser-languagedetector": "8.1.0",
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.9.8",
|
||||
"posthog-js": "1.232.6",
|
||||
"livekit-client": "2.11.4",
|
||||
"posthog-js": "1.240.6",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.7.1",
|
||||
"react-aria-components": "1.8.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.1.4",
|
||||
"wouter": "3.6.0"
|
||||
"valtio": "2.1.5",
|
||||
"wouter": "3.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.53.3",
|
||||
"@tanstack/eslint-plugin-query": "5.68.0",
|
||||
"@tanstack/react-query-devtools": "5.69.0",
|
||||
"@types/node": "22.13.13",
|
||||
"@pandacss/dev": "0.53.6",
|
||||
"@tanstack/eslint-plugin-query": "5.74.7",
|
||||
"@tanstack/react-query-devtools": "5.76.0",
|
||||
"@types/node": "22.15.17",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.28.0",
|
||||
"@typescript-eslint/parser": "8.28.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
||||
"@typescript-eslint/parser": "8.32.0",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "10.1.1",
|
||||
"eslint-config-prettier": "10.1.5",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.19",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"postcss": "8.5.3",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.8.2",
|
||||
"vite": "6.2.6",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "6.2.7",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -13,14 +13,17 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
|
||||
const isSDKContext = useIsSdkContext()
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppInitialization />
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface ApiConfig {
|
||||
support?: {
|
||||
id: string
|
||||
}
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
recording?: {
|
||||
|
||||
@@ -2,14 +2,10 @@ import { silenceLiveKitLogs } from '@/utils/livekit'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { useSupport } from '@/features/support/hooks/useSupport'
|
||||
import { useLocation } from 'wouter'
|
||||
import { useSyncUserPreferencesWithBackend } from '@/features/auth'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
export const AppInitialization = () => {
|
||||
const { data } = useConfig()
|
||||
const [location] = useLocation()
|
||||
useSyncUserPreferencesWithBackend()
|
||||
|
||||
const {
|
||||
@@ -18,10 +14,8 @@ export const AppInitialization = () => {
|
||||
silence_livekit_debug_logs = false,
|
||||
} = data || {}
|
||||
|
||||
const isSDKContext = location.includes(SDK_BASE_ROUTE)
|
||||
|
||||
useAnalytics({ ...analytics, isDisabled: isSDKContext })
|
||||
useSupport({ ...support, isDisabled: isSDKContext })
|
||||
useAnalytics(analytics)
|
||||
useSupport(support)
|
||||
|
||||
silenceLiveKitLogs(silence_livekit_debug_logs)
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@ import { css } from '@/styled-system/css'
|
||||
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text, A } from '@/primitives'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const FeedbackBanner = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data } = useConfig()
|
||||
|
||||
if (!data?.feedback?.url) return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -35,7 +39,7 @@ export const FeedbackBanner = () => {
|
||||
gap: 0.25,
|
||||
})}
|
||||
>
|
||||
<A href={GRIST_FEEDBACKS_FORM} target="_blank" size="sm">
|
||||
<A href={data?.feedback?.url} target="_blank" size="sm">
|
||||
{t('feedback.cta')}
|
||||
</A>
|
||||
<RiExternalLinkLine size={16} aria-hidden="true" />
|
||||
|
||||
@@ -26,7 +26,7 @@ export const fetchUser = (
|
||||
// make sure to not resolve the promise while trying to silent login
|
||||
// so that consumers of fetchUser don't think the work already ended
|
||||
if (opts.attemptSilent && canAttemptSilentLogin()) {
|
||||
attemptSilentLogin(300)
|
||||
attemptSilentLogin(30)
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useNotificationSound } from '@/features/notifications/hooks/useSoundNot
|
||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
||||
import {
|
||||
EMOJIS,
|
||||
Emoji,
|
||||
Reaction,
|
||||
} from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||
import {
|
||||
@@ -50,7 +50,7 @@ export const MainNotificationToast = () => {
|
||||
}, [room, triggerNotificationSound])
|
||||
|
||||
const handleEmoji = (emoji: string, participant: Participant) => {
|
||||
if (!emoji || !EMOJIS.includes(emoji)) return
|
||||
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
|
||||
const id = instanceIdRef.current++
|
||||
setReactions((prev) => [
|
||||
...prev,
|
||||
|
||||
@@ -11,5 +11,6 @@ export const NotificationDuration = {
|
||||
PARTICIPANT_JOINED: ToastDuration.LONG,
|
||||
HAND_RAISED: ToastDuration.LONG,
|
||||
LOWER_HAND: ToastDuration.EXTRA_LONG,
|
||||
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||
} as const
|
||||
|
||||
@@ -10,4 +10,5 @@ export enum NotificationType {
|
||||
TranscriptionStopped = 'transcriptionStopped',
|
||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||
RecordingSaving = 'recordingSaving',
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface ToastData {
|
||||
participant: Participant
|
||||
type: NotificationType
|
||||
message?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// Using a global queue for toasts allows for centralized management and queuing of notifications
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
|
||||
export function ToastRecordingSaving({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications', { keyPrefix: 'recordingSave' })
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
|
||||
const { user } = useUser()
|
||||
|
||||
const modeLabel = useMemo(() => {
|
||||
const mode = props.toast.content.mode as RecordingMode
|
||||
switch (mode) {
|
||||
case RecordingMode.Transcript:
|
||||
return 'transcript'
|
||||
case RecordingMode.ScreenRecording:
|
||||
return 'screenRecording'
|
||||
}
|
||||
}, [props.toast.content])
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={1}
|
||||
>
|
||||
<Text
|
||||
margin={false}
|
||||
className={css({
|
||||
maxWidth: '22rem',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
})}
|
||||
>
|
||||
{user?.email ? (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t(`${modeLabel}.message`, {
|
||||
email: user.email,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t(`${modeLabel}.default`)
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { ToastMuted } from './ToastMuted'
|
||||
import { ToastMessageReceived } from './ToastMessageReceived'
|
||||
import { ToastLowerHand } from './ToastLowerHand'
|
||||
import { ToastAnyRecording } from './ToastAnyRecording'
|
||||
import { ToastRecordingSaving } from './ToastRecordingSaving'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -43,6 +44,11 @@ const renderToast = (
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.RecordingSaving:
|
||||
return (
|
||||
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
|
||||
)
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useNotifyParticipants } from './hooks/useNotifyParticipants'
|
||||
export { NotificationType } from './NotificationType'
|
||||
export { notifyRecordingSaveInProgress } from './utils'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NotificationType } from './NotificationType'
|
||||
import { NotificationDuration } from './NotificationDuration'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { NotificationPayload } from './NotificationPayload'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
|
||||
export const showLowerHandToast = (
|
||||
participant: Participant,
|
||||
@@ -49,3 +50,17 @@ export const decodeNotificationDataReceived = (
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export const notifyRecordingSaveInProgress = (
|
||||
mode: RecordingMode,
|
||||
participant: Participant
|
||||
) => {
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
mode,
|
||||
type: NotificationType.RecordingSaving,
|
||||
},
|
||||
{ timeout: NotificationDuration.RECORDING_SAVING }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,5 +27,6 @@ export function useStartRecording(
|
||||
return useMutation<ApiRoom, ApiError, StartRecordingParams>({
|
||||
mutationFn: startRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: options?.onError,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,5 +19,6 @@ export function useStopRecording(
|
||||
return useMutation<ApiRoom, ApiError, StopRecordingParams>({
|
||||
mutationFn: stopRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: options?.onError,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,14 +106,10 @@ export const RecordingStateToast = () => {
|
||||
switch (recordingSnap.status) {
|
||||
case RecordingStatus.TRANSCRIPT_STARTED:
|
||||
return 'transcript.started'
|
||||
case RecordingStatus.TRANSCRIPT_STOPPING:
|
||||
return 'transcript.stopping'
|
||||
case RecordingStatus.TRANSCRIPT_STARTING:
|
||||
return 'transcript.starting'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTED:
|
||||
return 'screenRecording.started'
|
||||
case RecordingStatus.SCREEN_RECORDING_STOPPING:
|
||||
return 'screenRecording.stopping'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTING:
|
||||
return 'screenRecording.starting'
|
||||
case RecordingStatus.ANY_STARTED:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { A, Button, Div, H, Text } from '@/primitives'
|
||||
import { A, Button, Dialog, Div, H, P, Text } from '@/primitives'
|
||||
|
||||
import fourthSlide from '@/assets/intro-slider/4_record.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -6,19 +6,20 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useIsRecordingTransitioning,
|
||||
useStartRecording,
|
||||
useStopRecording,
|
||||
} from '@/features/recording'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
|
||||
import { useIsRecordingTransitioning } from '@/features/recording'
|
||||
|
||||
import {
|
||||
useNotifyParticipants,
|
||||
NotificationType,
|
||||
notifyRecordingSaveInProgress,
|
||||
useNotifyParticipants,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
@@ -29,12 +30,20 @@ export const ScreenRecordingSidePanel = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
|
||||
|
||||
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => setIsErrorDialogOpen('start'),
|
||||
})
|
||||
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => setIsErrorDialogOpen('stop'),
|
||||
})
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
return {
|
||||
@@ -50,6 +59,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
}, [recordingSnap])
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -75,6 +85,10 @@ export const ScreenRecordingSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStopped,
|
||||
})
|
||||
notifyRecordingSaveInProgress(
|
||||
RecordingMode.ScreenRecording,
|
||||
room.localParticipant
|
||||
)
|
||||
} else {
|
||||
await startRecordingRoom({
|
||||
id: roomId,
|
||||
@@ -94,8 +108,11 @@ export const ScreenRecordingSidePanel = () => {
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
|
||||
[isLoading, isRecordingTransitioning, statuses]
|
||||
isLoading ||
|
||||
isRecordingTransitioning ||
|
||||
statuses.isAnotherModeStarted ||
|
||||
!isRoomConnected,
|
||||
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -145,7 +162,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statuses.isStopping ? (
|
||||
{statuses.isStopping || isPendingToStop ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stopping.heading')}
|
||||
@@ -193,7 +210,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{statuses.isStarting ? (
|
||||
{statuses.isStarting || isPendingToStart ? (
|
||||
<>
|
||||
<Spinner size={20} />
|
||||
{t('start.loading')}
|
||||
@@ -206,6 +223,20 @@ export const ScreenRecordingSidePanel = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={!!isErrorDialogOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('alert.title')}
|
||||
>
|
||||
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setIsErrorDialogOpen('')}
|
||||
>
|
||||
{t('alert.button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { A, Button, Div, H, LinkButton, Text } from '@/primitives'
|
||||
import { A, Button, Dialog, Div, H, LinkButton, P, Text } from '@/primitives'
|
||||
|
||||
import thirdSlide from '@/assets/intro-slider/3_resume.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
useIsRecordingTransitioning,
|
||||
useStartRecording,
|
||||
useStopRecording,
|
||||
useHasFeatureWithoutAdminRights,
|
||||
} from '../index'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import {
|
||||
NotificationType,
|
||||
useNotifyParticipants,
|
||||
notifyRecordingSaveInProgress,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
@@ -32,6 +34,8 @@ export const TranscriptSidePanel = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
|
||||
|
||||
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
|
||||
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
@@ -40,10 +44,23 @@ export const TranscriptSidePanel = () => {
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
|
||||
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => setIsErrorDialogOpen('start'),
|
||||
})
|
||||
|
||||
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => setIsErrorDialogOpen('stop'),
|
||||
})
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
return {
|
||||
@@ -58,6 +75,7 @@ export const TranscriptSidePanel = () => {
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
@@ -82,6 +100,10 @@ export const TranscriptSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionStopped,
|
||||
})
|
||||
notifyRecordingSaveInProgress(
|
||||
RecordingMode.Transcript,
|
||||
room.localParticipant
|
||||
)
|
||||
} else {
|
||||
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
|
||||
@@ -98,8 +120,11 @@ export const TranscriptSidePanel = () => {
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
|
||||
[isLoading, isRecordingTransitioning, statuses]
|
||||
isLoading ||
|
||||
isRecordingTransitioning ||
|
||||
statuses.isAnotherModeStarted ||
|
||||
!isRoomConnected,
|
||||
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -121,30 +146,54 @@ export const TranscriptSidePanel = () => {
|
||||
/>
|
||||
{!hasTranscriptAccess ? (
|
||||
<>
|
||||
<Text>{t('beta.heading')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('beta.body')}{' '}
|
||||
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
</Text>
|
||||
<LinkButton
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
href={BETA_USERS_FORM_URL}
|
||||
target="_blank"
|
||||
>
|
||||
{t('beta.button')}
|
||||
</LinkButton>
|
||||
{hasFeatureWithoutAdminRights ? (
|
||||
<>
|
||||
<Text>{t('notAdminOrOwner.heading')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap="balance"
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('notAdminOrOwner.body')}
|
||||
<br />
|
||||
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
|
||||
{t('notAdminOrOwner.linkMore')}
|
||||
</A>
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>{t('beta.heading')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('beta.body')}{' '}
|
||||
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
</Text>
|
||||
<LinkButton
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
href={BETA_USERS_FORM_URL}
|
||||
target="_blank"
|
||||
>
|
||||
{t('beta.button')}
|
||||
</LinkButton>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -177,7 +226,7 @@ export const TranscriptSidePanel = () => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statuses.isStopping ? (
|
||||
{statuses.isStopping || isPendingToStop ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stopping.heading')}
|
||||
@@ -225,7 +274,7 @@ export const TranscriptSidePanel = () => {
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{statuses.isStarting ? (
|
||||
{statuses.isStarting || isPendingToStart ? (
|
||||
<>
|
||||
<Spinner size={20} />
|
||||
{t('start.loading')}
|
||||
@@ -240,6 +289,20 @@ export const TranscriptSidePanel = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={!!isErrorDialogOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('alert.title')}
|
||||
>
|
||||
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setIsErrorDialogOpen('')}
|
||||
>
|
||||
{t('alert.button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { RecordingMode } from '../types'
|
||||
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
export const useHasFeatureWithoutAdminRights = (
|
||||
mode: RecordingMode,
|
||||
featureFlag: FeatureFlags
|
||||
) => {
|
||||
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isRecordingModeEnabled &&
|
||||
!isAdminOrOwner
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
|
||||
export { useIsRecordingTransitioning } from './hooks/useIsRecordingTransitioning'
|
||||
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
|
||||
export { useIsRecordingActive } from './hooks/useIsRecordingActive'
|
||||
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
|
||||
|
||||
// api
|
||||
export { useStartRecording } from './api/startRecording'
|
||||
|
||||
@@ -353,7 +353,6 @@ export const Join = ({
|
||||
type="text"
|
||||
onChange={setUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
defaultValue={initialUserChoices?.username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { styled, VStack } from '@/styled-system/jsx'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { PostHog } from 'posthog-js'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
|
||||
const Card = styled('div', {
|
||||
base: {
|
||||
@@ -299,6 +300,7 @@ const AuthenticationMessage = ({
|
||||
}
|
||||
|
||||
export const Rating = () => {
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const posthog = usePostHog()
|
||||
|
||||
const isUserAnonymous = useMemo(() => {
|
||||
@@ -307,6 +309,8 @@ export const Rating = () => {
|
||||
|
||||
const [step, setStep] = useState(0)
|
||||
|
||||
if (!isAnalyticsEnabled) return
|
||||
|
||||
if (step == 0) {
|
||||
return <RateQuality posthog={posthog} onNext={() => setStep(step + 1)} />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export { Room as RoomRoute } from './routes/Room'
|
||||
export { FeedbackRoute } from './routes/Feedback'
|
||||
export { roomIdPattern, isRoomValid } from './utils/isRoomValid'
|
||||
export {
|
||||
roomIdPattern,
|
||||
isRoomValid,
|
||||
flexibleRoomIdPattern,
|
||||
} from './utils/isRoomValid'
|
||||
export { generateRoomId } from './utils/generateRoomId'
|
||||
export { useCreateRoom } from './api/createRoom'
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParticipantInfo } from '@livekit/components-react'
|
||||
import { Participant } from 'livekit-client'
|
||||
|
||||
export const ParticipantName = ({
|
||||
participant,
|
||||
isScreenShare = false,
|
||||
}: {
|
||||
participant: Participant
|
||||
isScreenShare: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
||||
|
||||
const { identity, name } = useParticipantInfo({ participant })
|
||||
const displayedName = name != '' ? name : identity
|
||||
|
||||
if (isScreenShare) {
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
marginLeft: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{t('screenShare', { name: displayedName })}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
}}
|
||||
>
|
||||
{displayedName}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
LockLockedIcon,
|
||||
ParticipantName,
|
||||
ParticipantTileProps,
|
||||
ScreenShareIcon,
|
||||
useEnsureTrackRef,
|
||||
@@ -29,6 +28,7 @@ import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -97,6 +97,8 @@ export const ParticipantTile: (
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
@@ -129,45 +131,50 @@ export const ParticipantTile: (
|
||||
{!disableMetadata && (
|
||||
<div className="lk-participant-metadata">
|
||||
<HStack gap={0.25}>
|
||||
<MutedMicIndicator
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
{!isScreenShare && (
|
||||
<MutedMicIndicator
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="lk-participant-metadata-item"
|
||||
style={{
|
||||
minHeight: '24px',
|
||||
backgroundColor: isHandRaised ? 'white' : undefined,
|
||||
color: isHandRaised ? 'black' : undefined,
|
||||
padding: '0.1rem 0.25rem',
|
||||
backgroundColor:
|
||||
isHandRaised && !isScreenShare ? 'white' : undefined,
|
||||
color:
|
||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||
transition: 'background 200ms ease, color 400ms ease',
|
||||
}}
|
||||
>
|
||||
{trackReference.source === Track.Source.Camera ? (
|
||||
<>
|
||||
{isHandRaised && (
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginInlineEnd: '.25rem', // fixme - match TrackMutedIndicator styling
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && (
|
||||
<LockLockedIcon
|
||||
style={{ marginRight: '0.25rem' }}
|
||||
/>
|
||||
)}
|
||||
<ParticipantName />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
|
||||
<ParticipantName>'s screen</ParticipantName>
|
||||
</>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
style={{
|
||||
maxWidth: '20px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
|
||||
@@ -15,6 +15,7 @@ export const INITIAL_POSITION = 200
|
||||
interface FloatingReactionProps {
|
||||
emoji: string
|
||||
name?: string
|
||||
isLocal?: boolean
|
||||
speed?: number
|
||||
scale?: number
|
||||
}
|
||||
@@ -22,6 +23,7 @@ interface FloatingReactionProps {
|
||||
export function FloatingReaction({
|
||||
emoji,
|
||||
name,
|
||||
isLocal = false,
|
||||
speed = 1,
|
||||
scale = 1,
|
||||
}: FloatingReactionProps) {
|
||||
@@ -60,7 +62,6 @@ export function FloatingReaction({
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3rem',
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -69,23 +70,27 @@ export function FloatingReaction({
|
||||
style={{
|
||||
left: left,
|
||||
bottom: INITIAL_POSITION + deltaY,
|
||||
transform: `scale(${scale})`,
|
||||
opacity: opacity,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
<img
|
||||
src={`/assets/reactions/${emoji}.png`}
|
||||
alt={''}
|
||||
className={css({
|
||||
lineHeight: '57px',
|
||||
height: '50px',
|
||||
})}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center bottom',
|
||||
}}
|
||||
/>
|
||||
{name && (
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.100',
|
||||
color: 'white',
|
||||
backgroundColor: isLocal ? 'primary.100' : 'primaryDark.100',
|
||||
color: isLocal ? 'black' : 'white',
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
borderRadius: '20px',
|
||||
paddingX: '0.5rem',
|
||||
@@ -128,6 +133,7 @@ export function ReactionPortal({
|
||||
speed={speed}
|
||||
scale={scale}
|
||||
name={participant?.isLocal ? t('you') : participant.name}
|
||||
isLocal={participant?.isLocal}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -110,9 +110,10 @@ const StyledSidePanel = ({
|
||||
type PanelProps = {
|
||||
isOpen: boolean
|
||||
children: React.ReactNode
|
||||
keepAlive?: boolean
|
||||
}
|
||||
|
||||
const Panel = ({ isOpen, children }: PanelProps) => (
|
||||
const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
||||
<div
|
||||
style={{
|
||||
display: isOpen ? 'inherit' : 'none',
|
||||
@@ -121,7 +122,7 @@ const Panel = ({ isOpen, children }: PanelProps) => (
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{keepAlive || isOpen ? children : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -160,7 +161,7 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isEffectsOpen}>
|
||||
<Effects />
|
||||
</Panel>
|
||||
<Panel isOpen={isChatOpen}>
|
||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isToolsOpen}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProcessorOptions, Track } from 'livekit-client'
|
||||
import { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
|
||||
import posthog from 'posthog-js'
|
||||
import {
|
||||
FilesetResolver,
|
||||
@@ -11,19 +11,20 @@ import {
|
||||
TIMEOUT_TICK,
|
||||
timerWorkerScript,
|
||||
} from './TimerWorker'
|
||||
import {
|
||||
BackgroundProcessorInterface,
|
||||
BackgroundOptions,
|
||||
ProcessorType,
|
||||
} from '.'
|
||||
import { ProcessorType } from '.'
|
||||
|
||||
const PROCESSING_WIDTH = 256 * 3
|
||||
const PROCESSING_HEIGHT = 144 * 3
|
||||
|
||||
const FACE_LANDMARKS_CANVAS_ID = 'face-landmarks-local'
|
||||
|
||||
export class FaceLandmarksProcessor implements BackgroundProcessorInterface {
|
||||
options: BackgroundOptions
|
||||
export type FaceLandmarksOptions = {
|
||||
showGlasses: boolean
|
||||
showFrench: boolean
|
||||
}
|
||||
|
||||
export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
||||
options: FaceLandmarksOptions
|
||||
name: string
|
||||
processedTrack?: MediaStreamTrack | undefined
|
||||
|
||||
@@ -50,7 +51,7 @@ export class FaceLandmarksProcessor implements BackgroundProcessorInterface {
|
||||
glassesImage?: HTMLImageElement
|
||||
mustacheImage?: HTMLImageElement
|
||||
beretImage?: HTMLImageElement
|
||||
constructor(opts: BackgroundOptions) {
|
||||
constructor(opts: FaceLandmarksOptions) {
|
||||
this.name = 'face_landmarks'
|
||||
this.options = opts
|
||||
this.type = ProcessorType.FACE_LANDMARKS
|
||||
@@ -314,7 +315,7 @@ export class FaceLandmarksProcessor implements BackgroundProcessorInterface {
|
||||
return element
|
||||
}
|
||||
|
||||
update(opts: BackgroundOptions): void {
|
||||
update(opts: FaceLandmarksOptions): void {
|
||||
this.options = opts
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,10 @@ import { Track, TrackProcessor } from 'livekit-client'
|
||||
import { BackgroundBlurTrackProcessorJsWrapper } from './BackgroundBlurTrackProcessorJsWrapper'
|
||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||
import { BackgroundVirtualTrackProcessorJsWrapper } from './BackgroundVirtualTrackProcessorJsWrapper'
|
||||
import { FaceLandmarksProcessor } from './FaceLandmarksProcessor'
|
||||
|
||||
export type BackgroundOptions = {
|
||||
blurRadius?: number
|
||||
imagePath?: string
|
||||
showGlasses?: boolean
|
||||
showFrench?: boolean
|
||||
}
|
||||
|
||||
export interface ProcessorSerialized {
|
||||
@@ -32,12 +29,12 @@ export enum ProcessorType {
|
||||
}
|
||||
|
||||
export class BackgroundProcessorFactory {
|
||||
static hasModernApiSupport() {
|
||||
return ProcessorWrapper.hasModernApiSupport
|
||||
}
|
||||
|
||||
static isSupported() {
|
||||
return (
|
||||
ProcessorWrapper.isSupported ||
|
||||
BackgroundCustomProcessor.isSupported ||
|
||||
FaceLandmarksProcessor.isSupported
|
||||
)
|
||||
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
|
||||
}
|
||||
|
||||
static getProcessor(
|
||||
@@ -58,10 +55,6 @@ export class BackgroundProcessorFactory {
|
||||
if (BackgroundCustomProcessor.isSupported) {
|
||||
return new BackgroundCustomProcessor(opts)
|
||||
}
|
||||
} else if (type === ProcessorType.FACE_LANDMARKS) {
|
||||
if (FaceLandmarksProcessor.isSupported) {
|
||||
return new FaceLandmarksProcessor(opts)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import { RiMegaphoneLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const FeedbackMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { data } = useConfig()
|
||||
|
||||
if (!data?.feedback?.url) return
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
href={GRIST_FEEDBACKS_FORM}
|
||||
href={data?.feedback?.url}
|
||||
target="_blank"
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
|
||||
@@ -12,9 +12,19 @@ import {
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { Toolbar as RACToolbar } from 'react-aria-components'
|
||||
import { Participant } from 'livekit-client'
|
||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const EMOJIS = ['👍', '👎', '👏', '❤️', '😂', '😮', '🎉']
|
||||
export enum Emoji {
|
||||
THUMBS_UP = 'thumbs-up',
|
||||
THUMBS_DOWN = 'thumbs-down',
|
||||
CLAP = 'clapping-hands',
|
||||
HEART = 'red-heart',
|
||||
LAUGHING = 'face-with-tears-of-joy',
|
||||
SURPRISED = 'face-with-open-mouth',
|
||||
CELEBRATION = 'party-popper',
|
||||
PLEASE = 'folded-hands',
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
id: number
|
||||
@@ -56,6 +66,12 @@ export const ReactionsToggle = () => {
|
||||
}, ANIMATION_DURATION)
|
||||
}
|
||||
|
||||
const debouncedSendReaction = useRateLimiter({
|
||||
callback: sendReaction,
|
||||
maxCalls: 10,
|
||||
windowMs: 1000,
|
||||
})
|
||||
|
||||
// Custom animation implementation for the emoji toolbar
|
||||
// Could not use a menu and its animation, because a menu would make the toolbar inaccessible by keyboard
|
||||
// animation isn't perfect
|
||||
@@ -105,10 +121,10 @@ export const ReactionsToggle = () => {
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: -55,
|
||||
left: -114,
|
||||
top: -63,
|
||||
left: -162,
|
||||
borderRadius: '8px',
|
||||
padding: '0.25rem',
|
||||
padding: '0.35rem',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
opacity: opacity,
|
||||
transition: 'opacity 0.2s ease',
|
||||
@@ -122,24 +138,29 @@ export const ReactionsToggle = () => {
|
||||
<RACToolbar
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{EMOJIS.map((emoji, index) => (
|
||||
{Object.values(Emoji).map((emoji, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => sendReaction(emoji)}
|
||||
onPress={() => debouncedSendReaction(emoji)}
|
||||
aria-label={t('send', { emoji })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
square
|
||||
data-attr={`send-reaction-${emoji}`}
|
||||
>
|
||||
<span
|
||||
<img
|
||||
src={`/assets/reactions/${emoji}.png`}
|
||||
alt=""
|
||||
className={css({
|
||||
fontSize: '20px',
|
||||
minHeight: '28px',
|
||||
minWidth: '28px',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
})}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</RACToolbar>
|
||||
|
||||
@@ -15,12 +15,9 @@ import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
|
||||
import { useTrackToggle } from '@livekit/components-react'
|
||||
import { Loader } from '@/primitives/Loader'
|
||||
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
||||
import {
|
||||
RiProhibited2Line,
|
||||
RiGlassesLine,
|
||||
RiGoblet2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { useHasFaceLandmarksAccess } from '../../hooks/useHasFaceLandmarksAccess'
|
||||
import { RiProhibited2Line } from '@remixicon/react'
|
||||
import { FunnyEffects } from './FunnyEffects'
|
||||
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
@@ -55,7 +52,7 @@ export const EffectsConfiguration = ({
|
||||
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
|
||||
const [processorPending, setProcessorPending] = useState(false)
|
||||
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
||||
const hasFaceLandmarksAccess = useHasFaceLandmarksAccess()
|
||||
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
@@ -114,6 +111,13 @@ export const EffectsConfiguration = ({
|
||||
type,
|
||||
options
|
||||
)!
|
||||
// IMPORTANT: Must explicitly stop previous processor before setting a new one
|
||||
// in browsers without modern API support to prevent UI crashes.
|
||||
// This workaround is needed until this issue is resolved:
|
||||
// https://github.com/livekit/track-processors-js/issues/85
|
||||
if (!BackgroundProcessorFactory.hasModernApiSupport()) {
|
||||
await videoTrack.stopProcessor()
|
||||
}
|
||||
await videoTrack.setProcessor(newProcessor)
|
||||
onSubmit?.(newProcessor)
|
||||
} else {
|
||||
@@ -145,42 +149,9 @@ export const EffectsConfiguration = ({
|
||||
}
|
||||
|
||||
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
if (type === ProcessorType.FACE_LANDMARKS) {
|
||||
const effect = options.showGlasses ? 'glasses' : 'french'
|
||||
return t(
|
||||
`faceLandmarks.${effect}.${isSelected(type, options) ? 'clear' : 'apply'}`
|
||||
)
|
||||
}
|
||||
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
|
||||
}
|
||||
|
||||
const getFaceLandmarksOptions = () => {
|
||||
const processor = getProcessor()
|
||||
if (processor?.serialize().type === ProcessorType.FACE_LANDMARKS) {
|
||||
return processor.serialize().options as {
|
||||
showGlasses?: boolean
|
||||
showFrench?: boolean
|
||||
}
|
||||
}
|
||||
return { showGlasses: false, showFrench: false }
|
||||
}
|
||||
|
||||
const toggleFaceLandmarkEffect = async (effect: 'glasses' | 'french') => {
|
||||
const currentOptions = getFaceLandmarksOptions()
|
||||
const newOptions = {
|
||||
...currentOptions,
|
||||
[effect === 'glasses' ? 'showGlasses' : 'showFrench']:
|
||||
!currentOptions[effect === 'glasses' ? 'showGlasses' : 'showFrench'],
|
||||
}
|
||||
|
||||
if (!newOptions.showGlasses && !newOptions.showFrench) {
|
||||
// If both effects are off stop the processor
|
||||
await clearEffect()
|
||||
} else {
|
||||
await toggleEffect(ProcessorType.FACE_LANDMARKS, newOptions)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css(
|
||||
@@ -215,7 +186,7 @@ export const EffectsConfiguration = ({
|
||||
muted
|
||||
style={{
|
||||
transform: 'rotateY(180deg)',
|
||||
minHeight: '175px',
|
||||
[layout === 'vertical' ? 'height' : 'minHeight']: '175px',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
@@ -268,6 +239,13 @@ export const EffectsConfiguration = ({
|
||||
: {}
|
||||
)}
|
||||
>
|
||||
{hasFunnyEffectsAccess && (
|
||||
<FunnyEffects
|
||||
videoTrack={videoTrack}
|
||||
isPending={processorPendingReveal}
|
||||
onPending={setProcessorPending}
|
||||
/>
|
||||
)}
|
||||
{isSupported ? (
|
||||
<>
|
||||
<div>
|
||||
@@ -340,8 +318,6 @@ export const EffectsConfiguration = ({
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
</div>
|
||||
{hasFaceLandmarksAccess && (
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1.5rem',
|
||||
@@ -354,114 +330,51 @@ export const EffectsConfiguration = ({
|
||||
}}
|
||||
variant="bodyXsBold"
|
||||
>
|
||||
{t('faceLandmarks.title')}
|
||||
{t('virtual.title')}
|
||||
</H>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
|
||||
showGlasses: true,
|
||||
showFrench: false,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
|
||||
showGlasses: true,
|
||||
showFrench: false,
|
||||
})}
|
||||
isDisabled={processorPendingReveal}
|
||||
onChange={async () =>
|
||||
await toggleFaceLandmarkEffect('glasses')
|
||||
}
|
||||
isSelected={getFaceLandmarksOptions().showGlasses}
|
||||
data-attr="toggle-glasses"
|
||||
>
|
||||
<RiGlassesLine />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
|
||||
showGlasses: false,
|
||||
showFrench: true,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
|
||||
showGlasses: false,
|
||||
showFrench: true,
|
||||
})}
|
||||
isDisabled={processorPendingReveal}
|
||||
onChange={async () =>
|
||||
await toggleFaceLandmarkEffect('french')
|
||||
}
|
||||
isSelected={getFaceLandmarksOptions().showFrench}
|
||||
data-attr="toggle-french"
|
||||
>
|
||||
<RiGoblet2Fill />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<H
|
||||
lvl={3}
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
variant="bodyXsBold"
|
||||
>
|
||||
{t('virtual.title')}
|
||||
</H>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{[...Array(8).keys()].map((i) => {
|
||||
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
|
||||
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
|
||||
return (
|
||||
<ToggleButton
|
||||
key={i}
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
isDisabled={processorPendingReveal}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.VIRTUAL, {
|
||||
{[...Array(8).keys()].map((i) => {
|
||||
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
|
||||
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
|
||||
return (
|
||||
<ToggleButton
|
||||
key={i}
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailPath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-${i}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
isDisabled={processorPendingReveal}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailPath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-${i}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Information className={css({ marginTop: '1rem' })}>
|
||||
<Text variant="sm">⚠︎ {t('experimental')}</Text>
|
||||
</Information>
|
||||
</>
|
||||
) : (
|
||||
<Information>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { H, ToggleButton } from '@/primitives'
|
||||
import { ProcessorType } from '../blur'
|
||||
import { RiGlassesLine, RiGoblet2Fill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FaceLandmarksProcessor } from '../blur/FaceLandmarksProcessor'
|
||||
import { LocalVideoTrack } from 'livekit-client'
|
||||
|
||||
export type FunnyEffectsProps = {
|
||||
videoTrack: LocalVideoTrack
|
||||
isPending?: boolean
|
||||
onPending: (value: boolean) => void
|
||||
}
|
||||
|
||||
export const FunnyEffects = ({
|
||||
videoTrack,
|
||||
isPending,
|
||||
onPending,
|
||||
}: FunnyEffectsProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
||||
|
||||
const getOptions = () => {
|
||||
const processor = videoTrack?.getProcessor() as FaceLandmarksProcessor
|
||||
if (!processor || processor.type != ProcessorType.FACE_LANDMARKS) {
|
||||
return {
|
||||
showGlasses: false,
|
||||
showFrench: false,
|
||||
}
|
||||
}
|
||||
return processor.serialize().options
|
||||
}
|
||||
|
||||
const options = getOptions()
|
||||
|
||||
const toggleFaceLandmarkEffect = async (
|
||||
showEffect: 'showGlasses' | 'showFrench'
|
||||
) => {
|
||||
const options = getOptions()
|
||||
const processor = videoTrack?.getProcessor() as FaceLandmarksProcessor
|
||||
|
||||
const newOptions = {
|
||||
...options,
|
||||
[showEffect]: !options[showEffect],
|
||||
}
|
||||
|
||||
onPending(true)
|
||||
|
||||
try {
|
||||
if (!newOptions.showGlasses && !newOptions.showFrench) {
|
||||
await videoTrack.stopProcessor()
|
||||
} else if (options.showGlasses || options.showFrench) {
|
||||
await processor?.update(newOptions)
|
||||
} else {
|
||||
const newProcessor = new FaceLandmarksProcessor(newOptions)
|
||||
await videoTrack.setProcessor(newProcessor)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('could not update processor', e)
|
||||
} finally {
|
||||
onPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getLabelAction = (enabled: boolean) => (enabled ? 'clear' : 'apply')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<H
|
||||
lvl={3}
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
variant="bodyXsBold"
|
||||
>
|
||||
{t('faceLandmarks.title')}
|
||||
</H>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={t(
|
||||
`faceLandmarks.glasses.${getLabelAction(options.showGlasses)}`
|
||||
)}
|
||||
tooltip={t(
|
||||
`faceLandmarks.glasses.${getLabelAction(options.showGlasses)}`
|
||||
)}
|
||||
isDisabled={isPending}
|
||||
onChange={async () => await toggleFaceLandmarkEffect('showGlasses')}
|
||||
isSelected={options.showGlasses}
|
||||
data-attr="toggle-glasses"
|
||||
>
|
||||
<RiGlassesLine />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={t(
|
||||
`faceLandmarks.french.${getLabelAction(options.showFrench)}`
|
||||
)}
|
||||
tooltip={t(
|
||||
`faceLandmarks.french.${getLabelAction(options.showFrench)}`
|
||||
)}
|
||||
isDisabled={isPending}
|
||||
onChange={async () => await toggleFaceLandmarkEffect('showFrench')}
|
||||
isSelected={options.showFrench}
|
||||
data-attr="toggle-french"
|
||||
>
|
||||
<RiGoblet2Fill />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
export const useHasFaceLandmarksAccess = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
|
||||
return featureEnabled || !isAnalyticsEnabled
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import useKonami from '@/features/rooms/livekit/hooks/useKonami'
|
||||
import { konamiStore } from '@/stores/konami'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
export const useHasFunnyEffectsAccess = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
|
||||
const konamiSnap = useSnapshot(konamiStore)
|
||||
|
||||
useKonami(
|
||||
() =>
|
||||
(konamiStore.areFunnyEffectsEnabled = !konamiSnap.areFunnyEffectsEnabled)
|
||||
)
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) && konamiSnap.areFunnyEffectsEnabled
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Konami Code Detector Component
|
||||
* This implementation is taken from: vmarchesin/react-konami-code
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
export const KONAMI_CODE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65]
|
||||
|
||||
function useKonami(action: () => void, { code = KONAMI_CODE } = {}) {
|
||||
const [input, setInput] = useState<number[]>([])
|
||||
|
||||
const onKeyUp = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const newInput = input
|
||||
newInput.push(e.keyCode)
|
||||
newInput.splice(-code.length - 1, input.length - code.length)
|
||||
|
||||
setInput(newInput)
|
||||
|
||||
if (newInput.join('').includes(code.join(''))) {
|
||||
action()
|
||||
}
|
||||
},
|
||||
[input, setInput, code, action]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keyup', onKeyUp)
|
||||
return () => {
|
||||
document.removeEventListener('keyup', onKeyUp)
|
||||
}
|
||||
}, [onKeyUp])
|
||||
}
|
||||
|
||||
export default useKonami
|
||||
@@ -19,23 +19,38 @@ export function usePersistentUserChoices(
|
||||
)
|
||||
|
||||
const saveAudioInputEnabled = React.useCallback((isEnabled: boolean) => {
|
||||
setSettings((prev) => ({ ...prev, audioEnabled: isEnabled }))
|
||||
setSettings((prev: LocalUserChoices) => ({
|
||||
...prev,
|
||||
audioEnabled: isEnabled,
|
||||
}))
|
||||
}, [])
|
||||
const saveVideoInputEnabled = React.useCallback((isEnabled: boolean) => {
|
||||
setSettings((prev) => ({ ...prev, videoEnabled: isEnabled }))
|
||||
setSettings((prev: LocalUserChoices) => ({
|
||||
...prev,
|
||||
videoEnabled: isEnabled,
|
||||
}))
|
||||
}, [])
|
||||
const saveAudioInputDeviceId = React.useCallback((deviceId: string) => {
|
||||
setSettings((prev) => ({ ...prev, audioDeviceId: deviceId }))
|
||||
setSettings((prev: LocalUserChoices) => ({
|
||||
...prev,
|
||||
audioDeviceId: deviceId,
|
||||
}))
|
||||
}, [])
|
||||
const saveVideoInputDeviceId = React.useCallback((deviceId: string) => {
|
||||
setSettings((prev) => ({ ...prev, videoDeviceId: deviceId }))
|
||||
setSettings((prev: LocalUserChoices) => ({
|
||||
...prev,
|
||||
videoDeviceId: deviceId,
|
||||
}))
|
||||
}, [])
|
||||
const saveUsername = React.useCallback((username: string) => {
|
||||
setSettings((prev) => ({ ...prev, username: username }))
|
||||
setSettings((prev: LocalUserChoices) => ({ ...prev, username: username }))
|
||||
}, [])
|
||||
const saveProcessorSerialized = React.useCallback(
|
||||
(processorSerialized?: ProcessorSerialized) => {
|
||||
setSettings((prev) => ({ ...prev, processorSerialized }))
|
||||
setSettings((prev: LocalUserChoices) => ({
|
||||
...prev,
|
||||
processorSerialized,
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
RiMore2Line,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
@@ -24,6 +23,7 @@ import { useSettingsDialog } from '../../components/controls/SettingsDialogConte
|
||||
import { ResponsiveMenu } from './ResponsiveMenu'
|
||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export function MobileControlBar({
|
||||
onDeviceError,
|
||||
@@ -38,6 +38,8 @@ export function MobileControlBar({
|
||||
const { toggleEffects } = useSidePanel()
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -150,17 +152,19 @@ export function MobileControlBar({
|
||||
>
|
||||
<RiAccountBoxLine size={20} />
|
||||
</Button>
|
||||
<LinkButton
|
||||
href={GRIST_FEEDBACKS_FORM}
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('options.items.feedback')}
|
||||
aria-label={t('options.items.feedback')}
|
||||
description={true}
|
||||
target="_blank"
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
</LinkButton>
|
||||
{data?.feedback?.url && (
|
||||
<LinkButton
|
||||
href={data?.feedback?.url}
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('options.items.feedback')}
|
||||
aria-label={t('options.items.feedback')}
|
||||
description={true}
|
||||
target="_blank"
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
</LinkButton>
|
||||
)}
|
||||
<Button
|
||||
onPress={() => {
|
||||
setDialogOpen(true)
|
||||
|
||||
@@ -3,13 +3,17 @@ import {
|
||||
usePersistentUserChoices,
|
||||
type LocalUserChoices as LocalUserChoicesLK,
|
||||
} from '@livekit/components-react'
|
||||
import { useParams } from 'wouter'
|
||||
import { useLocation, useParams } from 'wouter'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { useUser, UserAware } from '@/features/auth'
|
||||
import { Conference } from '../components/Conference'
|
||||
import { Join } from '../components/Join'
|
||||
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
|
||||
import { ProcessorSerialized } from '../livekit/components/blur'
|
||||
import {
|
||||
isRoomValid,
|
||||
normalizeRoomId,
|
||||
} from '@/features/rooms/utils/isRoomValid'
|
||||
|
||||
export type LocalUserChoices = LocalUserChoicesLK & {
|
||||
processorSerialized?: ProcessorSerialized
|
||||
@@ -21,6 +25,7 @@ export const Room = () => {
|
||||
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
|
||||
|
||||
const { roomId } = useParams()
|
||||
const [location, setLocation] = useLocation()
|
||||
const initialRoomData = history.state?.initialRoomData
|
||||
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
|
||||
const skipJoinScreen = isLoggedIn && mode === 'create'
|
||||
@@ -40,6 +45,12 @@ export const Room = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (roomId && !isRoomValid(roomId)) {
|
||||
setLocation(normalizeRoomId(roomId))
|
||||
}
|
||||
}, [roomId, setLocation, location])
|
||||
|
||||
if (!roomId) {
|
||||
return <ErrorScreen />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
|
||||
|
||||
// Case-insensitive and with optional hyphens
|
||||
export const flexibleRoomIdPattern =
|
||||
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
|
||||
|
||||
export const isRoomValid = (roomIdOrUrl: string) =>
|
||||
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
|
||||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
|
||||
|
||||
export const normalizeRoomId = (roomId: string) => {
|
||||
const cleanId = roomId.toLowerCase().replace(/-/g, '')
|
||||
if (cleanId.length === 10) {
|
||||
return `${cleanId.slice(0, 3)}-${cleanId.slice(3, 7)}-${cleanId.slice(7, 10)}`
|
||||
}
|
||||
return roomId
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useLocation } from 'wouter'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
export const useIsSdkContext = () => {
|
||||
const [location] = useLocation()
|
||||
return location.includes(SDK_BASE_ROUTE)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type RateLimiterProps<T extends (...args: any[]) => any> = {
|
||||
callback: T
|
||||
maxCalls: number
|
||||
windowMs: number
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function useRateLimiter<T extends (...args: any[]) => any>({
|
||||
callback,
|
||||
maxCalls = 5,
|
||||
windowMs = 1000,
|
||||
}: RateLimiterProps<T>) {
|
||||
const callsCountRef = useRef(0)
|
||||
const resetTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
|
||||
const rateLimitedFn = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (callsCountRef.current < maxCalls) {
|
||||
callsCountRef.current += 1
|
||||
if (callsCountRef.current === 1) {
|
||||
resetTimeoutRef.current = setTimeout(() => {
|
||||
callsCountRef.current = 0
|
||||
resetTimeoutRef.current = undefined
|
||||
}, windowMs)
|
||||
}
|
||||
return callback(...args)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
[callback, maxCalls, windowMs]
|
||||
)
|
||||
return rateLimitedFn
|
||||
}
|
||||
@@ -14,7 +14,7 @@ i18n
|
||||
.use(initReactI18next)
|
||||
.use(LanguageDetector)
|
||||
.init({
|
||||
supportedLngs: ['en', 'fr', 'nl'],
|
||||
supportedLngs: ['en', 'fr', 'nl', 'de'],
|
||||
fallbackLng: 'fr',
|
||||
ns: i18nDefaultNamespace,
|
||||
detection: {
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
{
|
||||
"accessibility": {
|
||||
"title": "",
|
||||
"introduction": "",
|
||||
"title": "Barrierefreiheit",
|
||||
"introduction": "<i>Visio</i> verpflichtet sich, seine digitalen Dienste barrierefrei zu gestalten, gemäß Artikel 47 des Gesetzes Nr. 2005-102 vom 11. Februar 2005.",
|
||||
"declaration": {
|
||||
"title": "",
|
||||
"date": ""
|
||||
"title": "Erklärung zur Barrierefreiheit",
|
||||
"date": "Erstellt am 4. Dezember 2024."
|
||||
},
|
||||
"scope": "",
|
||||
"scope": "Diese Erklärung zur Barrierefreiheit gilt für die Website visio.numerique.gouv.fr",
|
||||
"complianceStatus": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
"title": "Stand der Vereinbarkeit",
|
||||
"body": "visio.numerique.gouv.fr ist nicht konform mit RGAA 4.1. Die Website wurde noch nicht geprüft. Das Team bemüht sich jedoch, eine für alle zugängliche Seite gemäß den Empfehlungen des RGAA zu erstellen."
|
||||
},
|
||||
"improvement": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"title": "Verbesserung und Kontakt",
|
||||
"body": "Wenn Sie auf Inhalte oder Dienste nicht zugreifen können, können Sie die verantwortliche Person von lasuite.numerique.gouv.fr kontaktieren, um eine barrierefreie Alternative zu erhalten oder die Inhalte in einer anderen Form zu bekommen.",
|
||||
"contact": {
|
||||
"email": "",
|
||||
"address": ""
|
||||
"email": "E-Mail: visio@numerique.gouv.fr",
|
||||
"address": "Adresse: DINUM, 20 avenue de Ségur, 75007 Paris"
|
||||
},
|
||||
"response": ""
|
||||
"response": "Wir bemühen uns, innerhalb von 2 Werktagen zu antworten."
|
||||
},
|
||||
"recourse": {
|
||||
"title": "",
|
||||
"introduction": "",
|
||||
"title": "Rechtsbehelfe",
|
||||
"introduction": "Dieses Verfahren ist in folgendem Fall zu verwenden: Sie haben der für die Website zuständigen Stelle ein Problem mit der Barrierefreiheit gemeldet, das Sie daran hindert, auf Inhalte oder Dienste des Portals zuzugreifen, und keine zufriedenstellende Antwort erhalten.",
|
||||
"options": {
|
||||
"intro": "",
|
||||
"option1": "",
|
||||
"option2": "",
|
||||
"option3": ""
|
||||
"intro": "Sie können:",
|
||||
"option1": "Dem*der Beauftragten für Rechte eine Nachricht schreiben",
|
||||
"option2": "Den*die Vertreter*in des*der Beauftragten für Rechte in Ihrer Region kontaktieren",
|
||||
"option3": "Einen Brief per Post senden (kostenfrei, ohne Briefmarke): </br> Défenseur des droits, Libre réponse 71120, 75342 Paris CEDEX 07"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
{
|
||||
"app": "Visio",
|
||||
"backToHome": "",
|
||||
"cancel": "",
|
||||
"closeDialog": "",
|
||||
"backToHome": "Zurück zur Startseite",
|
||||
"cancel": "Abbrechen",
|
||||
"closeDialog": "Dialogfenster schließen",
|
||||
"error": {
|
||||
"heading": ""
|
||||
"heading": "Beim Laden der Seite ist ein Fehler aufgetreten"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "",
|
||||
"cta": ""
|
||||
"context": "Produkt in Entwicklung — Ihr Feedback ist wichtig!",
|
||||
"cta": "Teilen Sie uns Ihre Meinung mit"
|
||||
},
|
||||
"forbidden": {
|
||||
"heading": ""
|
||||
"heading": "Zugriff verweigert"
|
||||
},
|
||||
"loading": "",
|
||||
"loggedInUserTooltip": "",
|
||||
"loading": "Ladevorgang…",
|
||||
"loggedInUserTooltip": "Angemeldet als…",
|
||||
"login": {
|
||||
"buttonLabel": "",
|
||||
"linkLabel": "",
|
||||
"link": ""
|
||||
"buttonLabel": "Mit ProConnect anmelden",
|
||||
"linkLabel": "Was ist ProConnect? – neues Fenster",
|
||||
"link": "Was ist ProConnect?"
|
||||
},
|
||||
"logout": "",
|
||||
"logout": "Abmelden",
|
||||
"notFound": {
|
||||
"heading": "",
|
||||
"body": ""
|
||||
"heading": "Überprüfen Sie Ihren Meeting-Code",
|
||||
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
|
||||
},
|
||||
"submit": "OK",
|
||||
"footer": {
|
||||
"links": {
|
||||
"legifrance": "",
|
||||
"infogouv": "",
|
||||
"servicepublic": "",
|
||||
"datagouv": "",
|
||||
"legalsTerms": "",
|
||||
"data": "",
|
||||
"accessibility": "",
|
||||
"ariaLabel": "",
|
||||
"codeAnnotation": "",
|
||||
"code": "",
|
||||
"technicalDetails": "",
|
||||
"termsOfService": ""
|
||||
"legifrance": "legifrance.gouv.fr",
|
||||
"infogouv": "info.gouv.fr",
|
||||
"servicepublic": "service-public.fr",
|
||||
"datagouv": "data.gouv.fr",
|
||||
"legalsTerms": "Rechtliche Hinweise",
|
||||
"data": "Datenschutz und Cookies",
|
||||
"accessibility": "Barrierefreiheit: nicht konform",
|
||||
"ariaLabel": "neues Fenster",
|
||||
"codeAnnotation": "Unser Code ist offen und verfügbar in diesem",
|
||||
"code": "Open-Source-Code-Repository",
|
||||
"technicalDetails": "Technisches Datenblatt",
|
||||
"termsOfService": "Nutzungsbedingungen"
|
||||
},
|
||||
"mentions": "",
|
||||
"license": ""
|
||||
"mentions": "Sofern nicht anders angegeben, sind die Inhalte dieser Website verfügbar unter der",
|
||||
"license": "Etalab 2.0 Lizenz"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"title": "Melden Sie sich mit Ihrem ProConnect-Konto an",
|
||||
"body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
|
||||
"button": {
|
||||
"ariaLabel": "",
|
||||
"label": ""
|
||||
"ariaLabel": "Hinweis schließen",
|
||||
"label": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
{
|
||||
"createMeeting": "",
|
||||
"heading": "",
|
||||
"intro": "",
|
||||
"joinInputError": "",
|
||||
"joinInputExample": "",
|
||||
"joinInputLabel": "",
|
||||
"joinInputSubmit": "",
|
||||
"joinMeeting": "",
|
||||
"joinMeetingTipContent": "",
|
||||
"joinMeetingTipHeading": "",
|
||||
"loginToCreateMeeting": "",
|
||||
"moreLinkLabel": "",
|
||||
"moreLink": "",
|
||||
"moreAbout": "",
|
||||
"createMeeting": "Meeting erstellen",
|
||||
"heading": "Einfache und sichere Videokonferenzen",
|
||||
"intro": "Kommunizieren und arbeiten Sie mühelos, ohne Ihre digitale Souveränität zu gefährden",
|
||||
"joinInputError": "Verwenden Sie einen Meeting-Link oder -Code. Beispiele:",
|
||||
"joinInputExample": "URL oder 10-stelliger Code",
|
||||
"joinInputLabel": "Meeting-Link",
|
||||
"joinInputSubmit": "Meeting beitreten",
|
||||
"joinMeeting": "Meeting beitreten",
|
||||
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
|
||||
"joinMeetingTipHeading": "Wussten Sie schon?",
|
||||
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
|
||||
"moreLinkLabel": "Mehr erfahren – neues Tab",
|
||||
"moreLink": "Mehr erfahren",
|
||||
"moreAbout": "über Visio",
|
||||
"createMenu": {
|
||||
"laterOption": "",
|
||||
"instantOption": ""
|
||||
"laterOption": "Meeting für später planen",
|
||||
"instantOption": "Sofort-Meeting starten"
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "",
|
||||
"description": "",
|
||||
"copy": "",
|
||||
"copied": "",
|
||||
"permissions": ""
|
||||
"heading": "Ihre Zugangsdaten",
|
||||
"description": "Senden Sie diesen Link an die Personen, die Sie zum Meeting einladen möchten. Sie können ohne ProConnect teilnehmen.",
|
||||
"copy": "Meeting-Link kopieren",
|
||||
"copied": "Link in die Zwischenablage kopiert",
|
||||
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten."
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
},
|
||||
"beta": {
|
||||
"text": "",
|
||||
"tooltip": ""
|
||||
"label": "Zurück",
|
||||
"tooltip": "Zurück"
|
||||
},
|
||||
"next": {
|
||||
"label": "",
|
||||
"tooltip": ""
|
||||
"label": "Weiter",
|
||||
"tooltip": "Weiter"
|
||||
},
|
||||
"beta": {
|
||||
"text": "An der Beta teilnehmen",
|
||||
"tooltip": "Formular ausfüllen"
|
||||
},
|
||||
"slide1": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"imgAlt": ""
|
||||
"title": "Testen Sie Visio für Ihre täglichen Aufgaben",
|
||||
"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"
|
||||
},
|
||||
"slide2": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"imgAlt": ""
|
||||
"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"
|
||||
},
|
||||
"slide3": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"imgAlt": ""
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
{
|
||||
"title": "",
|
||||
"title": "Rechtliche Hinweise",
|
||||
"creator": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"title": "Herausgeber",
|
||||
"body": "Der Visio-Dienst wird herausgegeben von der Interministeriellen Digitaldirektion des Staates (DINUM) mit Sitz:",
|
||||
"contact": {
|
||||
"title": "",
|
||||
"address": "",
|
||||
"city": "",
|
||||
"phone": "",
|
||||
"siret": "",
|
||||
"siren": ""
|
||||
"title": "Kontaktdaten",
|
||||
"address": "20 avenue de Ségur",
|
||||
"city": "75007 Paris",
|
||||
"phone": "Tel. Zentrale: 01.71.21.01.70",
|
||||
"siret": "SIRET: 12000101100010 (Generalsekretariat der Regierung)",
|
||||
"siren": "SIREN: 120 001 011"
|
||||
}
|
||||
},
|
||||
"director": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
"title": "Verantwortliche für die Veröffentlichung",
|
||||
"body": "Die Verantwortliche für die Veröffentlichung ist Frau Stéphanie Schaer, Interministerielle Direktorin für Digitalisierung."
|
||||
},
|
||||
"hosting": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
"title": "Hosting",
|
||||
"body": "Der Dienst wird gehostet von Outscale West-1 CloudGouv SecNumCloud mit Standort in Frankreich."
|
||||
},
|
||||
"accessibility": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"more": "",
|
||||
"link": "",
|
||||
"status": ""
|
||||
"title": "Barrierefreiheit",
|
||||
"body": "Die Einhaltung der Standards für digitale Barrierefreiheit ist ein zukünftiges Ziel. Die Website wurde noch nicht auditiert. Das Team bemüht sich jedoch, eine für alle zugängliche Website zu erstellen, unter Berücksichtigung der Empfehlungen des RGAA.",
|
||||
"more": "Mehr erfahren: ",
|
||||
"link": "Link zur Seite über Barrierefreiheit",
|
||||
"status": "Barrierefreiheitsstatus: nicht konform"
|
||||
},
|
||||
"reuse": {
|
||||
"title": "",
|
||||
"body1": "",
|
||||
"license": "",
|
||||
"body2": "",
|
||||
"body3": ""
|
||||
"title": "Wiederverwendung von Inhalten und Links",
|
||||
"body1": "Sofern nicht ausdrücklich anders angegeben und geistige Eigentumsrechte Dritter betroffen sind, werden die Inhalte dieser Website unter der ",
|
||||
"license": "offenen Lizenz Etalab 2.0",
|
||||
"body2": "bereitgestellt. Insbesondere dürfen Sie diese Inhalte frei vervielfältigen, kopieren, ändern, extrahieren, umwandeln, verbreiten, weiterleiten, veröffentlichen, übertragen und nutzen – unter der Bedingung, dass die Quelle und das Datum der letzten Aktualisierung genannt werden und Dritte nicht durch die Informationen in die Irre geführt werden.",
|
||||
"body3": "Jede öffentliche oder private Website ist berechtigt, ohne vorherige Genehmigung, einen Link (einschließlich eines Deep Links) zu den auf dieser Website veröffentlichten Informationen zu setzen."
|
||||
}
|
||||
}
|
||||
|
||||