mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-21 15:47:09 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a31253c72e | |||
| 029feea486 | |||
| 0bc554e331 | |||
| 627867d89e | |||
| d8add71d74 | |||
| a345b5cfe0 | |||
| e9184f3af2 | |||
| 617beb3340 | |||
| d7ab5f4f1f | |||
| 280ebdfe7f | |||
| 0caecbdfba | |||
| d77b187565 | |||
| 8cbcad7645 | |||
| 89f8480e0b | |||
| d9bf6efa2a | |||
| 4cb7412194 | |||
| e8df597055 | |||
| 05dfcd11ca | |||
| b018832fcf | |||
| a269160f6f | |||
| c3afa84d9b | |||
| 8c6752a29f | |||
| 4c57432a03 | |||
| 3b7bfd999c | |||
| 7f386b2e2f | |||
| c55d8235fd | |||
| c7b23abd68 | |||
| 5a641a4366 | |||
| f043ad6f98 | |||
| 1141c1cecd | |||
| 33792b050a | |||
| f4569c64e5 | |||
| 6a00d3d087 | |||
| 2e975e2643 | |||
| 4c63aa827f | |||
| 67e9bf2fef | |||
| 7124167947 | |||
| 759388c72f | |||
| a663b4dc76 | |||
| 73aa162dc8 | |||
| a3851842e9 | |||
| 77964c6a74 | |||
| 72b863e794 | |||
| 0e3c978af2 |
@@ -0,0 +1,29 @@
|
|||||||
|
# /!\
|
||||||
|
# Security Note: This action is not hardened against prompt injection attacks and should only be used
|
||||||
|
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
|
||||||
|
# to ensure workflows only run after a maintainer has reviewed the PR.
|
||||||
|
name: Security Review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write # Needed for leaving PR comments
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
|
||||||
|
with:
|
||||||
|
comment-pr: true
|
||||||
|
exclude-directories: docs,gitlint,LICENSES,bin
|
||||||
|
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
|
||||||
+1
-4
@@ -15,8 +15,6 @@ and this project adheres to
|
|||||||
- ✨(frontend) allow promoting authenticated participants
|
- ✨(frontend) allow promoting authenticated participants
|
||||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||||
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
||||||
- ✨(frontend) let users set default configuration for generated links
|
|
||||||
- ✨(frontend) expose media state to external gateways
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -31,7 +29,7 @@ and this project adheres to
|
|||||||
- 💄(frontend) improve participant name rendering in the list
|
- 💄(frontend) improve participant name rendering in the list
|
||||||
- 🚚(backend) rename TelephonyService to SIPManagement
|
- 🚚(backend) rename TelephonyService to SIPManagement
|
||||||
|
|
||||||
### Fixed
|
## Fixed
|
||||||
|
|
||||||
- 🐛(transcription) fix silent bug in speaker assignment
|
- 🐛(transcription) fix silent bug in speaker assignment
|
||||||
- 🐛(summary) extend tasks auto retry logic
|
- 🐛(summary) extend tasks auto retry logic
|
||||||
@@ -40,7 +38,6 @@ and this project adheres to
|
|||||||
- 🐛(backend) allow any string as sub in the API serializer
|
- 🐛(backend) allow any string as sub in the API serializer
|
||||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||||
- 🚸(frontend) show two initials in the Avatar when possible
|
- 🚸(frontend) show two initials in the Avatar when possible
|
||||||
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
|
|
||||||
|
|
||||||
## [1.24.0] - 2026-07-21
|
## [1.24.0] - 2026-07-21
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ create-env-files: \
|
|||||||
env.d/development/common \
|
env.d/development/common \
|
||||||
env.d/development/crowdin \
|
env.d/development/crowdin \
|
||||||
env.d/development/postgresql \
|
env.d/development/postgresql \
|
||||||
env.d/development/kc_postgresql \
|
|
||||||
env.d/development/summary \
|
env.d/development/summary \
|
||||||
env.d/development/kube-secret \
|
env.d/development/kube-secret \
|
||||||
env.d/development/multi_user_transcriber \
|
env.d/development/multi_user_transcriber \
|
||||||
@@ -288,9 +287,6 @@ env.d/development/common:
|
|||||||
env.d/development/postgresql:
|
env.d/development/postgresql:
|
||||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
||||||
|
|
||||||
env.d/development/kc_postgresql:
|
|
||||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
|
||||||
|
|
||||||
env.d/development/summary:
|
env.d/development/summary:
|
||||||
cp -n env.d/development/summary.dist env.d/development/summary
|
cp -n env.d/development/summary.dist env.d/development/summary
|
||||||
|
|
||||||
|
|||||||
+10
-34
@@ -147,7 +147,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- keycloak
|
- dex
|
||||||
- app-dev
|
- app-dev
|
||||||
networks:
|
networks:
|
||||||
- resource-server
|
- resource-server
|
||||||
@@ -187,40 +187,16 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ".:/app"
|
- ".:/app"
|
||||||
|
|
||||||
kc_postgresql:
|
# OIDC provider for the development stack. Dex uses in-memory storage, so it
|
||||||
image: postgres:14.3
|
# needs no database and no volume: restarting it rotates the signing keys and
|
||||||
ports:
|
# drops every active session, which is fine locally.
|
||||||
- "5433:5432"
|
dex:
|
||||||
env_file:
|
image: dexidp/dex:v2.45.1
|
||||||
- env.d/development/kc_postgresql
|
command: ["dex", "serve", "/etc/dex/config.yaml"]
|
||||||
|
|
||||||
keycloak:
|
|
||||||
image: quay.io/keycloak/keycloak:20.0.1
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
- ./docker/auth/dex.yaml:/etc/dex/config.yaml:ro
|
||||||
command:
|
expose:
|
||||||
- start-dev
|
- "5556"
|
||||||
- --features=preview
|
|
||||||
- --import-realm
|
|
||||||
- --proxy=edge
|
|
||||||
- --hostname-url=http://localhost:8083
|
|
||||||
- --hostname-admin-url=http://localhost:8083/
|
|
||||||
- --hostname-strict=false
|
|
||||||
- --hostname-strict-https=false
|
|
||||||
environment:
|
|
||||||
KEYCLOAK_ADMIN: admin
|
|
||||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
|
||||||
KC_DB: postgres
|
|
||||||
KC_DB_URL_HOST: kc_postgresql
|
|
||||||
KC_DB_URL_DATABASE: keycloak
|
|
||||||
KC_DB_PASSWORD: pass
|
|
||||||
KC_DB_USERNAME: meet
|
|
||||||
KC_DB_SCHEMA: public
|
|
||||||
PROXY_ADDRESS_FORWARDING: 'true'
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
depends_on:
|
|
||||||
- kc_postgresql
|
|
||||||
|
|
||||||
livekit:
|
livekit:
|
||||||
image: livekit/livekit-server
|
image: livekit/livekit-server
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Dex configuration for the local development stack.
|
||||||
|
#
|
||||||
|
# This file replaces the former Keycloak "meet" realm (docker/auth/realm.json).
|
||||||
|
# The client and the users below are a one-to-one port of that realm.
|
||||||
|
#
|
||||||
|
# Storage is in-memory on purpose: no database container, no volume, ~30 MB of
|
||||||
|
# RAM instead of the Keycloak + PostgreSQL pair. The trade-off is that
|
||||||
|
# restarting the `dex` service rotates the signing keys and drops every active
|
||||||
|
# session, so you have to log in again.
|
||||||
|
|
||||||
|
# Must match OIDC_OP_URL in env.d/development/common. Dex serves all of its
|
||||||
|
# endpoints under the path component of the issuer, i.e. /dex/auth, /dex/token,
|
||||||
|
# /dex/keys, /dex/userinfo and /dex/.well-known/openid-configuration.
|
||||||
|
issuer: http://localhost:8083/dex
|
||||||
|
|
||||||
|
storage:
|
||||||
|
type: memory
|
||||||
|
|
||||||
|
web:
|
||||||
|
http: 0.0.0.0:5556
|
||||||
|
allowedOrigins:
|
||||||
|
- http://localhost:3000
|
||||||
|
- http://localhost:8071
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: info
|
||||||
|
format: text
|
||||||
|
|
||||||
|
oauth2:
|
||||||
|
# Logging in implies authorization: no consent screen, as with the realm.
|
||||||
|
skipApprovalScreen: true
|
||||||
|
|
||||||
|
expiry:
|
||||||
|
idTokens: 24h
|
||||||
|
signingKeys: 6h
|
||||||
|
|
||||||
|
staticClients:
|
||||||
|
- id: meet
|
||||||
|
name: Meet
|
||||||
|
secret: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
|
# Dex does not support wildcards: every callback URL must be listed
|
||||||
|
# explicitly. The path is the one exposed by mozilla-django-oidc through
|
||||||
|
# lasuite.oidc_login, mounted under api/<version>/ by core.urls.
|
||||||
|
redirectURIs:
|
||||||
|
- http://localhost:3000/api/v1.0/callback/
|
||||||
|
- http://localhost:3200/api/v1.0/callback/
|
||||||
|
- http://localhost:8070/api/v1.0/callback/
|
||||||
|
- http://localhost:8071/api/v1.0/callback/
|
||||||
|
- http://localhost:8088/api/v1.0/callback/
|
||||||
|
|
||||||
|
enablePasswordDB: true
|
||||||
|
|
||||||
|
# Dex's local password database authenticates on the *email address*, not on
|
||||||
|
# the username, so the login is now "meet@meet.world" (password unchanged).
|
||||||
|
#
|
||||||
|
# Hashes are bcrypt with cost 10, the minimum dex accepts. To add a user:
|
||||||
|
# htpasswd -bnBC 10 "" <password> | tr -d ':\n'
|
||||||
|
staticPasswords:
|
||||||
|
- email: meet@meet.world
|
||||||
|
hash: "$2b$10$qVCVTnaF67S/7a.pQM4djOgpj61FxD/yz6LoiQdtX0TKISelAfZxC"
|
||||||
|
username: meet
|
||||||
|
name: John Doe
|
||||||
|
preferredUsername: John
|
||||||
|
userID: 4ad6106f-a64f-43eb-ad0e-380d2cad9a9d
|
||||||
|
groups:
|
||||||
|
- user
|
||||||
|
|
||||||
|
- email: user@chromium.e2e
|
||||||
|
hash: "$2b$10$4Rs3Jd/Q23RM09g7c1Z/yeGmEjoAYlMKXDBkkjERaRDlz0Doiwl2q"
|
||||||
|
username: user-e2e-chromium
|
||||||
|
name: E2E Chromium
|
||||||
|
preferredUsername: E2E
|
||||||
|
userID: 1cd83dfc-153f-4987-b8a6-a2ac72d39122
|
||||||
|
groups:
|
||||||
|
- user
|
||||||
|
|
||||||
|
- email: user@webkit.e2e
|
||||||
|
hash: "$2b$10$D50UlVVMA7qWlB.Pw8P02eMJpo8qfwWuGiA63IeTqq/3mAE7RyH3m"
|
||||||
|
username: user-e2e-webkit
|
||||||
|
name: E2E Webkit
|
||||||
|
preferredUsername: E2E
|
||||||
|
userID: 9b9bd390-a6e5-42f8-a06d-9a11ede7bb8c
|
||||||
|
groups:
|
||||||
|
- user
|
||||||
|
|
||||||
|
- email: user@firefox.e2e
|
||||||
|
hash: "$2b$10$0D8WW7.KXMkzSY2b9JhwYeIM3WkTPQCwGd36/G3TZ/HHh4ObCVRga"
|
||||||
|
username: user-e2e-firefox
|
||||||
|
name: E2E Firefox
|
||||||
|
preferredUsername: E2E
|
||||||
|
userID: ec3e8750-7629-42f1-a0c3-6e23968a2fba
|
||||||
|
groups:
|
||||||
|
- user
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://keycloak:8080;
|
proxy_pass http://dex:5556;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
|
|
||||||
# You can add any necessary service here that will join the same docker network
|
# You can add any necessary service here that will join the same docker network
|
||||||
# sharing keycloak. Services added to the 'meet_resource-server' network will be
|
# sharing the OIDC provider. Services added to the 'meet_resource-server'
|
||||||
# able to communicate with keycloak and the backend on that network.
|
# network will be able to communicate with dex (through nginx) and the backend
|
||||||
|
# on that network.
|
||||||
services:
|
services:
|
||||||
# busybox service is only used for testing purposes. It provides curl to test
|
# busybox service is only used for testing purposes. It provides curl to test
|
||||||
# connectivity to the backend and keycloak services. Replace this with your
|
# connectivity to the backend and the OIDC provider. Replace this with your
|
||||||
# relevant application services that need to communicate with keycloak.
|
# relevant application services that need to communicate with them.
|
||||||
busybox:
|
busybox:
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
privileged: true
|
privileged: true
|
||||||
|
|||||||
@@ -71,8 +71,12 @@ $ make bootstrap FLUSH_ARGS='--no-input'
|
|||||||
|
|
||||||
2. Access the project:
|
2. Access the project:
|
||||||
- The frontend is available at [http://localhost:3000](http://localhost:3000) with the default credentials:
|
- The frontend is available at [http://localhost:3000](http://localhost:3000) with the default credentials:
|
||||||
- username: meet
|
- email: meet@meet.world
|
||||||
- password: meet
|
- password: meet
|
||||||
|
|
||||||
|
Authentication is handled by [dex](https://dexidp.io/), configured in
|
||||||
|
`docker/auth/dex.yaml`. It logs you in by email address, and its storage is
|
||||||
|
in-memory: restarting the `dex` container logs everyone out.
|
||||||
- The Django backend is available at [http://localhost:8071](http://localhost:8071)
|
- The Django backend is available at [http://localhost:8071](http://localhost:8071)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -31,24 +31,35 @@ MEDIA_BASE_URL=http://localhost:3000
|
|||||||
FILE_UPLOAD_ENABLED=True
|
FILE_UPLOAD_ENABLED=True
|
||||||
|
|
||||||
# OIDC
|
# OIDC
|
||||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
# Provider is dex (docker/auth/dex.yaml), served behind nginx on port 8083.
|
||||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth
|
# Endpoints reached by the browser use localhost, the ones called server-side
|
||||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token
|
# by the backend use the nginx service name.
|
||||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo
|
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/dex/keys
|
||||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token/introspect
|
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/dex/auth
|
||||||
OIDC_OP_URL=http://localhost:8083/realms/meet
|
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/dex/token
|
||||||
|
OIDC_OP_USER_ENDPOINT=http://nginx:8083/dex/userinfo
|
||||||
|
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/dex/token/introspect
|
||||||
|
OIDC_OP_URL=http://localhost:8083/dex
|
||||||
|
|
||||||
OIDC_RP_CLIENT_ID=meet
|
OIDC_RP_CLIENT_ID=meet
|
||||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
OIDC_RP_SIGN_ALGO=RS256
|
OIDC_RP_SIGN_ALGO=RS256
|
||||||
OIDC_RP_SCOPES="openid email"
|
# "profile" is required: dex only emits the name claims under that scope.
|
||||||
|
OIDC_RP_SCOPES="openid email profile"
|
||||||
|
|
||||||
|
# Dex exposes the display name through the standard "name" and
|
||||||
|
# "preferred_username" claims and never emits given_name/family_name.
|
||||||
|
OIDC_USERINFO_FULLNAME_FIELDS=name
|
||||||
|
OIDC_USERINFO_SHORTNAME_FIELD=preferred_username
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL=http://localhost:3000
|
LOGIN_REDIRECT_URL=http://localhost:3000
|
||||||
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
|
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
|
||||||
LOGOUT_REDIRECT_URL=http://localhost:3000
|
LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||||
|
|
||||||
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
|
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
|
||||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
# Dex has no notion of ACR, the eIDAS level requested from ProConnect in
|
||||||
|
# production is meaningless here and would just be ignored.
|
||||||
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS={}
|
||||||
|
|
||||||
OIDC_RS_CLIENT_ID=meet
|
OIDC_RS_CLIENT_ID=meet
|
||||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# Postgresql db container configuration
|
|
||||||
POSTGRES_DB=keycloak
|
|
||||||
POSTGRES_USER=meet
|
|
||||||
POSTGRES_PASSWORD=pass
|
|
||||||
|
|
||||||
# App database configuration
|
|
||||||
DB_HOST=kc_postgresql
|
|
||||||
DB_NAME=keycloak
|
|
||||||
DB_USER=meet
|
|
||||||
DB_PASSWORD=pass
|
|
||||||
DB_PORT=5433
|
|
||||||
@@ -61,9 +61,6 @@ def get_frontend_configuration(request):
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"telephony": build_telephony_config(),
|
"telephony": build_telephony_config(),
|
||||||
"resource": {
|
|
||||||
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
|
||||||
},
|
|
||||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
|
|||||||
@@ -31,28 +31,9 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = [
|
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
|
||||||
"id",
|
|
||||||
"email",
|
|
||||||
"full_name",
|
|
||||||
"short_name",
|
|
||||||
"timezone",
|
|
||||||
"language",
|
|
||||||
"default_room_access_level",
|
|
||||||
"default_room_configuration",
|
|
||||||
]
|
|
||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||||
|
|
||||||
def validate_default_room_configuration(self, value):
|
|
||||||
"""Validate the default room configuration against the RoomConfiguration schema."""
|
|
||||||
if value is None or value == {}:
|
|
||||||
return value
|
|
||||||
try:
|
|
||||||
RoomConfiguration.model_validate(value)
|
|
||||||
except PydanticValidationError as e:
|
|
||||||
raise serializers.ValidationError(e.errors()) from e
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class UserLightSerializer(serializers.ModelSerializer):
|
class UserLightSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize users with limited fields."""
|
"""Serialize users with limited fields."""
|
||||||
|
|||||||
@@ -308,27 +308,8 @@ class RoomViewSet(
|
|||||||
return drf_response.Response(serializer.data)
|
return drf_response.Response(serializer.data)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Set the current user as owner of the newly created room.
|
"""Set the current user as owner of the newly created room."""
|
||||||
|
room = serializer.save()
|
||||||
Apply the user's default room preferences (access level and configuration)
|
|
||||||
unless the request explicitly provides its own values.
|
|
||||||
"""
|
|
||||||
user = self.request.user
|
|
||||||
save_kwargs = {}
|
|
||||||
|
|
||||||
if (
|
|
||||||
"access_level" not in serializer.validated_data
|
|
||||||
and user.default_room_access_level not in (None, "")
|
|
||||||
):
|
|
||||||
save_kwargs["access_level"] = user.default_room_access_level
|
|
||||||
|
|
||||||
user_default_configuration = user.default_room_configuration
|
|
||||||
if not serializer.validated_data.get(
|
|
||||||
"configuration"
|
|
||||||
) and user_default_configuration not in (None, {}):
|
|
||||||
save_kwargs["configuration"] = user.default_room_configuration
|
|
||||||
|
|
||||||
room = serializer.save(**save_kwargs)
|
|
||||||
models.ResourceAccess.objects.create(
|
models.ResourceAccess.objects.create(
|
||||||
resource=room,
|
resource=room,
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.2.14 on 2026-08-03 13:40
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('core', '0021_recording_external_process_id_alter_recording_status'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='default_room_access_level',
|
|
||||||
field=models.CharField(blank=True, choices=[('public', 'Public Access'), ('trusted', 'Trusted Access'), ('restricted', 'Restricted Access')], help_text='Access level applied by default to new rooms created by this user. When empty, the instance default is used.', max_length=50, null=True, verbose_name='default room access level'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='default_room_configuration',
|
|
||||||
field=models.JSONField(blank=True, default=dict, help_text='Configurations applied by default to new rooms created by this user.', verbose_name='default room configuration'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -189,25 +189,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
default=settings.TIME_ZONE,
|
default=settings.TIME_ZONE,
|
||||||
help_text=_("The timezone in which the user wants to see times."),
|
help_text=_("The timezone in which the user wants to see times."),
|
||||||
)
|
)
|
||||||
default_room_access_level = models.CharField(
|
|
||||||
max_length=50,
|
|
||||||
choices=RoomAccessLevel.choices,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name=_("default room access level"),
|
|
||||||
help_text=_(
|
|
||||||
"Access level applied by default to new rooms created by this user. "
|
|
||||||
"When empty, the instance default is used."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
default_room_configuration = models.JSONField(
|
|
||||||
blank=True,
|
|
||||||
default=dict,
|
|
||||||
verbose_name=_("default room configuration"),
|
|
||||||
help_text=_(
|
|
||||||
"Configurations applied by default to new rooms created by this user."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
is_device = models.BooleanField(
|
is_device = models.BooleanField(
|
||||||
_("device"),
|
_("device"),
|
||||||
default=False,
|
default=False,
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
"""
|
|
||||||
Celery task decorator that degrades to a synchronous call when Celery is off.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# The Celery app is imported lazily so that importing this module does not pull
|
|
||||||
# in Celery when CELERY_ENABLED is false.
|
|
||||||
# ruff: noqa: PLC0415
|
# ruff: noqa: PLC0415
|
||||||
# pylint: disable=import-outside-toplevel
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ Test rooms API endpoints in the Meet core app: create.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
from django.conf import settings
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import Room, RoomAccessLevel
|
from ...models import Room
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -110,205 +109,3 @@ def test_api_rooms_create_authenticated_existing_slug():
|
|||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_user_default_access_level():
|
|
||||||
"""
|
|
||||||
The user's default room access level should be applied to the new room
|
|
||||||
when the request does not provide one.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_explicit_access_level_overrides_default():
|
|
||||||
"""
|
|
||||||
An access level explicitly provided in the request should take precedence
|
|
||||||
over the user's default room access level.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
"access_level": RoomAccessLevel.TRUSTED,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_no_user_default_access_level():
|
|
||||||
"""
|
|
||||||
When the user has no default room access level, the instance default
|
|
||||||
should be applied to the new room.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_access_level=None)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_user_default_configuration():
|
|
||||||
"""
|
|
||||||
The user's default room configuration should be applied to the new room
|
|
||||||
when the request does not provide one.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {"everyone_can_mute": False}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_explicit_configuration_overrides_default():
|
|
||||||
"""
|
|
||||||
A configuration explicitly provided in the request should take precedence
|
|
||||||
over the user's default room configuration.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_empty_configuration_falls_back_to_default():
|
|
||||||
"""
|
|
||||||
An empty configuration in the request should not be considered an explicit
|
|
||||||
value: the user's default room configuration should still be applied.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={"everyone_can_mute": True})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
"configuration": {},
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {"everyone_can_mute": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_empty_user_default_configuration():
|
|
||||||
"""
|
|
||||||
When the user's default room configuration is empty, the new room should
|
|
||||||
keep its default empty configuration.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_request_precedence_over_user_empty():
|
|
||||||
"""
|
|
||||||
When the user's default room configuration is empty, the request should take precedence.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{"name": "my room", "configuration": {"everyone_can_mute": True}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {"everyone_can_mute": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_blank_user_default_access_level():
|
|
||||||
"""
|
|
||||||
A blank default room access level (stored as an empty string) should be
|
|
||||||
treated as unset: the instance default should be applied to the new room
|
|
||||||
instead of persisting an invalid empty access level.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_access_level="")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
|
||||||
|
|||||||
@@ -453,8 +453,6 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
{
|
{
|
||||||
"id": str(other_user_access.id),
|
"id": str(other_user_access.id),
|
||||||
"user": {
|
"user": {
|
||||||
"default_room_access_level": None,
|
|
||||||
"default_room_configuration": {},
|
|
||||||
"id": str(other_user_access.user.id),
|
"id": str(other_user_access.user.id),
|
||||||
"email": other_user_access.user.email,
|
"email": other_user_access.user.email,
|
||||||
"full_name": other_user_access.user.full_name,
|
"full_name": other_user_access.user.full_name,
|
||||||
@@ -468,8 +466,6 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
{
|
{
|
||||||
"id": str(user_access.id),
|
"id": str(user_access.id),
|
||||||
"user": {
|
"user": {
|
||||||
"default_room_access_level": None,
|
|
||||||
"default_room_configuration": {},
|
|
||||||
"id": str(user_access.user.id),
|
"id": str(user_access.user.id),
|
||||||
"email": user_access.user.email,
|
"email": user_access.user.email,
|
||||||
"full_name": user_access.user.full_name,
|
"full_name": user_access.user.full_name,
|
||||||
|
|||||||
@@ -119,8 +119,6 @@ def test_api_users_retrieve_me_authenticated(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"default_room_access_level": None,
|
|
||||||
"default_room_configuration": {},
|
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"full_name": user.full_name,
|
"full_name": user.full_name,
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
"""
|
|
||||||
Test the default room preferences exposed on the users API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from core import factories
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_me_includes_default_room_preferences():
|
|
||||||
"""The "me" endpoint should expose the user's default room preferences."""
|
|
||||||
user = factories.UserFactory(
|
|
||||||
default_room_access_level="restricted",
|
|
||||||
default_room_configuration={"everyone_can_mute": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/users/me/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.json()
|
|
||||||
assert content["default_room_access_level"] == "restricted"
|
|
||||||
assert content["default_room_configuration"] == {"everyone_can_mute": False}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_update_default_room_preferences():
|
|
||||||
"""Users should be able to update their own default room preferences."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/users/{user.id!s}/",
|
|
||||||
{
|
|
||||||
"default_room_access_level": "trusted",
|
|
||||||
"default_room_configuration": {
|
|
||||||
"can_publish_sources": ["microphone", "camera"],
|
|
||||||
"everyone_can_mute": False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
user.refresh_from_db()
|
|
||||||
assert user.default_room_access_level == "trusted"
|
|
||||||
assert user.default_room_configuration == {
|
|
||||||
"can_publish_sources": ["microphone", "camera"],
|
|
||||||
"everyone_can_mute": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_update_default_room_access_level_invalid():
|
|
||||||
"""An invalid access level should be rejected."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/users/{user.id!s}/",
|
|
||||||
{"default_room_access_level": "invalid"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
user.refresh_from_db()
|
|
||||||
assert user.default_room_access_level is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_update_default_room_configuration_invalid():
|
|
||||||
"""An invalid room configuration should be rejected."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/users/{user.id!s}/",
|
|
||||||
{"default_room_configuration": {"unknown_field": True}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
user.refresh_from_db()
|
|
||||||
assert user.default_room_configuration == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_update_other_user_default_room_preferences_forbidden():
|
|
||||||
"""Users should not be able to update someone else's preferences."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
other_user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/users/{other_user.id!s}/",
|
|
||||||
{"default_room_access_level": "restricted"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
other_user.refresh_from_db()
|
|
||||||
assert other_user.default_room_access_level is None
|
|
||||||
@@ -2,7 +2,6 @@ import { fetchApi } from './fetchApi'
|
|||||||
import { keys } from './queryKeys'
|
import { keys } from './queryKeys'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { RecordingMode } from '@/features/recording'
|
import { RecordingMode } from '@/features/recording'
|
||||||
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
|
||||||
import type { Track } from 'livekit-client'
|
import type { Track } from 'livekit-client'
|
||||||
type Source = Track.Source
|
type Source = Track.Source
|
||||||
|
|
||||||
@@ -50,9 +49,6 @@ export interface ApiConfig {
|
|||||||
international_phone_number?: string
|
international_phone_number?: string
|
||||||
default_country?: string
|
default_country?: string
|
||||||
}
|
}
|
||||||
resource?: {
|
|
||||||
default_access_level?: ApiAccessLevel
|
|
||||||
}
|
|
||||||
manifest_link?: string
|
manifest_link?: string
|
||||||
livekit: {
|
livekit: {
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import { BackendLanguage } from '@/utils/languages'
|
import { BackendLanguage } from '@/utils/languages'
|
||||||
import type {
|
|
||||||
ApiAccessLevel,
|
|
||||||
RoomConfiguration,
|
|
||||||
} from '@/features/rooms/api/ApiRoom'
|
|
||||||
|
|
||||||
export type ApiUser = {
|
export type ApiUser = {
|
||||||
id: string
|
id: string
|
||||||
@@ -11,6 +7,4 @@ export type ApiUser = {
|
|||||||
last_name: string
|
last_name: string
|
||||||
language: BackendLanguage
|
language: BackendLanguage
|
||||||
timezone: string
|
timezone: string
|
||||||
default_room_access_level?: ApiAccessLevel | null
|
|
||||||
default_room_configuration?: RoomConfiguration | null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
|
||||||
import type { ApiError } from '@/api/ApiError'
|
|
||||||
import { type ApiUser } from './ApiUser'
|
|
||||||
|
|
||||||
export type PatchUserParams = {
|
|
||||||
userId: string
|
|
||||||
user: Partial<
|
|
||||||
Pick<
|
|
||||||
ApiUser,
|
|
||||||
| 'timezone'
|
|
||||||
| 'language'
|
|
||||||
| 'default_room_access_level'
|
|
||||||
| 'default_room_configuration'
|
|
||||||
>
|
|
||||||
>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const patchUser = ({ userId, user }: PatchUserParams) => {
|
|
||||||
return fetchApi<ApiUser>(`/users/${userId}/`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(user),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const patchUserMutationKey = ['patchUser']
|
|
||||||
|
|
||||||
export function usePatchUser(
|
|
||||||
options?: UseMutationOptions<ApiUser, ApiError, PatchUserParams>
|
|
||||||
) {
|
|
||||||
return useMutation<ApiUser, ApiError, PatchUserParams>({
|
|
||||||
mutationKey: patchUserMutationKey,
|
|
||||||
mutationFn: patchUser,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { useLocalParticipant } from '@livekit/components-react'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
export const MEDIA_STATE_ELEMENT_ID = 'media-state'
|
|
||||||
export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
|
||||||
|
|
||||||
export type MediaStateChangedDetail = {
|
|
||||||
microphoneEnabled: boolean
|
|
||||||
cameraEnabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exposes the local participant's media state in the DOM so external tools
|
|
||||||
* (e.g. bots automating the frontend) can reliably read the microphone and
|
|
||||||
* camera state, and watch for changes with a MutationObserver:
|
|
||||||
*
|
|
||||||
* const el = document.getElementById('media-state')
|
|
||||||
* new MutationObserver(...).observe(el, { attributes: true })
|
|
||||||
*/
|
|
||||||
export const MediaStateObserver = () => {
|
|
||||||
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent<MediaStateChangedDetail>(MEDIA_STATE_CHANGED_EVENT, {
|
|
||||||
detail: {
|
|
||||||
microphoneEnabled: isMicrophoneEnabled,
|
|
||||||
cameraEnabled: isCameraEnabled,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}, [isMicrophoneEnabled, isCameraEnabled])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id={MEDIA_STATE_ELEMENT_ID}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
data-microphone-enabled={isMicrophoneEnabled ? 'true' : 'false'}
|
|
||||||
data-camera-enabled={isCameraEnabled ? 'true' : 'false'}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import { SidePanel } from '../components/SidePanel'
|
|||||||
import { RecordingProvider } from '@/features/recording'
|
import { RecordingProvider } from '@/features/recording'
|
||||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||||
import { ConnectionObserver } from '../components/ConnectionObserver'
|
import { ConnectionObserver } from '../components/ConnectionObserver'
|
||||||
import { MediaStateObserver } from '../components/MediaStateObserver'
|
|
||||||
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
||||||
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
||||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||||
@@ -64,7 +63,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<>
|
<>
|
||||||
<RoomMetadataSynchronizer />
|
<RoomMetadataSynchronizer />
|
||||||
<ConnectionObserver />
|
<ConnectionObserver />
|
||||||
<MediaStateObserver />
|
|
||||||
<ChatProvider />
|
<ChatProvider />
|
||||||
<VideoResolutionSubscription />
|
<VideoResolutionSubscription />
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ export class CallbackIdHandler {
|
|||||||
private readonly storageKey = 'popup_callback_id'
|
private readonly storageKey = 'popup_callback_id'
|
||||||
|
|
||||||
private generateId(): string {
|
private generateId(): string {
|
||||||
// The id is the only thing guarding /rooms/creation-callback/, which is
|
return (
|
||||||
// unauthenticated, so it comes from the CSPRNG rather than Math.random.
|
Math.random().toString(36).substring(2, 15) +
|
||||||
const bytes = new Uint8Array(16)
|
Math.random().toString(36).substring(2, 15)
|
||||||
crypto.getRandomValues(bytes)
|
|
||||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
|
||||||
''
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +1,22 @@
|
|||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { useRef } from 'react'
|
|
||||||
import { Heading } from 'react-aria-components'
|
|
||||||
import { RiSettings3Line, RiDoorOpenLine } from '@remixicon/react'
|
|
||||||
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||||
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
||||||
import { Tab, TabList, TabPanel, Tabs } from '@/primitives/Tabs'
|
|
||||||
import { text } from '@/primitives/Text.tsx'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { logout } from '@/features/auth/utils/logout'
|
import { logout } from '@/features/auth/utils/logout'
|
||||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
|
||||||
import { RoomsTab } from './tabs/RoomsTab'
|
|
||||||
|
|
||||||
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
||||||
|
|
||||||
enum SettingsDialogTabKey {
|
|
||||||
GENERAL = 'general',
|
|
||||||
ROOMS = 'rooms',
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabsStyle = css({
|
|
||||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
|
||||||
width: '50rem', // fixme size copied from meet settings modal
|
|
||||||
marginY: '-1rem', // fixme hacky solution to cancel modal padding
|
|
||||||
maxWidth: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
height: 'calc(100vh - 2rem)',
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabListContainerStyle = css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
borderRight: '1px solid lightGray', // fixme poor color management
|
|
||||||
paddingY: '1rem',
|
|
||||||
paddingLeft: '0.2rem',
|
|
||||||
paddingRight: '1.5rem',
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabPanelContainerStyle = css({
|
|
||||||
display: 'flex',
|
|
||||||
flexGrow: '1',
|
|
||||||
marginTop: '3.5rem',
|
|
||||||
minWidth: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabPanelStyle = css({
|
|
||||||
flexGrow: '1',
|
|
||||||
minWidth: 0,
|
|
||||||
overflowY: 'auto',
|
|
||||||
paddingRight: '1.5rem',
|
|
||||||
paddingBottom: '1rem',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const SettingsDialog = (props: SettingsDialogProps) => {
|
export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||||
const { t, i18n } = useTranslation('settings')
|
const { t, i18n } = useTranslation('settings')
|
||||||
const { user, isLoggedIn } = useUser()
|
const { user, isLoggedIn } = useUser()
|
||||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||||
|
|
||||||
const dialogEl = useRef<HTMLDivElement>(null)
|
|
||||||
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
|
||||||
|
|
||||||
const userDisplay =
|
const userDisplay =
|
||||||
user?.full_name && user?.email
|
user?.full_name && user?.email
|
||||||
? `${user.full_name} (${user.email})`
|
? `${user.full_name} (${user.email})`
|
||||||
: user?.email
|
: user?.email
|
||||||
|
return (
|
||||||
const generalContent = (
|
<Dialog title={t('dialog.heading')} {...props}>
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minWidth: '360px',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<H lvl={2}>{t('account.heading')}</H>
|
<H lvl={2}>{t('account.heading')}</H>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
@@ -104,56 +47,6 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
|||||||
i18n.changeLanguage(lang as string)
|
i18n.changeLanguage(lang as string)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Without tabs there is no rail to host the heading, so keep the plain dialog.
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
return (
|
|
||||||
<Dialog title={t('dialog.heading')} {...props} role="dialog" type="flex">
|
|
||||||
{generalContent}
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
|
||||||
<Tabs
|
|
||||||
orientation="vertical"
|
|
||||||
className={tabsStyle}
|
|
||||||
defaultSelectedKey={SettingsDialogTabKey.GENERAL}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={tabListContainerStyle}
|
|
||||||
style={{
|
|
||||||
flex: isWideScreen ? '0 0 16rem' : undefined,
|
|
||||||
paddingTop: !isWideScreen ? '64px' : undefined,
|
|
||||||
paddingRight: !isWideScreen ? '1rem' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isWideScreen && (
|
|
||||||
<Heading slot="title" level={1} className={text({ variant: 'h1' })}>
|
|
||||||
{t('dialog.heading')}
|
|
||||||
</Heading>
|
|
||||||
)}
|
|
||||||
<TabList border={false}>
|
|
||||||
<Tab icon highlight id={SettingsDialogTabKey.GENERAL}>
|
|
||||||
<RiSettings3Line />
|
|
||||||
{isWideScreen && t(`tabs.${SettingsDialogTabKey.GENERAL}`)}
|
|
||||||
</Tab>
|
|
||||||
<Tab icon highlight id={SettingsDialogTabKey.ROOMS}>
|
|
||||||
<RiDoorOpenLine />
|
|
||||||
{isWideScreen && t(`tabs.${SettingsDialogTabKey.ROOMS}`)}
|
|
||||||
</Tab>
|
|
||||||
</TabList>
|
|
||||||
</div>
|
|
||||||
<div className={tabPanelContainerStyle}>
|
|
||||||
<TabPanel id={SettingsDialogTabKey.GENERAL} className={tabPanelStyle}>
|
|
||||||
{generalContent}
|
|
||||||
</TabPanel>
|
|
||||||
<RoomsTab id={SettingsDialogTabKey.ROOMS} />
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
|
||||||
import {
|
|
||||||
usePatchUser,
|
|
||||||
patchUserMutationKey,
|
|
||||||
} from '@/features/auth/api/patchUser'
|
|
||||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
|
||||||
import { ApiAccessLevel, RoomConfiguration } from '@/features/rooms/api/ApiRoom'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { queryClient } from '@/api/queryClient'
|
|
||||||
import { keys } from '@/api/queryKeys'
|
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
import Source = Track.Source
|
|
||||||
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
|
||||||
import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
|
||||||
import { Field, H, Text } from '@/primitives'
|
|
||||||
import { TabPanel } from '@/primitives/Tabs'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Separator as RACSeparator } from 'react-aria-components'
|
|
||||||
|
|
||||||
type RoomsTabProps = {
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RoomsTab = ({ id }: RoomsTabProps) => {
|
|
||||||
const { t } = useTranslation('settings', { keyPrefix: 'roomDefaults' })
|
|
||||||
const { t: tAdmin } = useTranslation('rooms', {
|
|
||||||
keyPrefix: 'admin',
|
|
||||||
useSuspense: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { user } = useUser()
|
|
||||||
const { data: configData } = useConfig()
|
|
||||||
|
|
||||||
// Optimistic updates: patch the cache immediately so the UI updates
|
|
||||||
// instantly and concurrent saves always build on the latest local state.
|
|
||||||
// Since each PATCH replaces the full JSON config, this avoids overwriting
|
|
||||||
// earlier changes with a stale snapshot.
|
|
||||||
//
|
|
||||||
// No per-request rollback: later requests already include earlier changes.
|
|
||||||
// Once the last in-flight save completes, re-fetch the server state once to
|
|
||||||
// restore the UI if all saves failed.
|
|
||||||
const { mutate: patchUser } = usePatchUser({
|
|
||||||
onMutate: async ({ user: partialUser }) => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: [keys.user] })
|
|
||||||
queryClient.setQueryData<ApiUser | false>([keys.user], (previous) =>
|
|
||||||
previous ? { ...previous, ...partialUser } : previous
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
if (queryClient.isMutating({ mutationKey: patchUserMutationKey }) === 1) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [keys.user] })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const configuration: RoomConfiguration = useMemo(
|
|
||||||
() => user?.default_room_configuration ?? {},
|
|
||||||
[user?.default_room_configuration]
|
|
||||||
)
|
|
||||||
|
|
||||||
const currentSources: Source[] = useMemo(() => {
|
|
||||||
const defaultSources = configData?.livekit?.default_sources ?? []
|
|
||||||
if (!Array.isArray(configuration?.can_publish_sources)) {
|
|
||||||
return defaultSources
|
|
||||||
}
|
|
||||||
return configuration.can_publish_sources
|
|
||||||
}, [configData, configuration])
|
|
||||||
|
|
||||||
const accessLevel =
|
|
||||||
user?.default_room_access_level ??
|
|
||||||
configData?.resource?.default_access_level ??
|
|
||||||
ApiAccessLevel.PUBLIC
|
|
||||||
|
|
||||||
// Every change saves immediately; the optimistic onMutate above keeps the
|
|
||||||
// cached user (and therefore `configuration`) in sync right away.
|
|
||||||
const saveConfiguration = (newConfiguration: RoomConfiguration) => {
|
|
||||||
if (!user) return
|
|
||||||
patchUser({
|
|
||||||
userId: user.id,
|
|
||||||
user: { default_room_configuration: newConfiguration },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSource = (sources: Source[], enabled: boolean) =>
|
|
||||||
saveConfiguration({
|
|
||||||
...configuration,
|
|
||||||
can_publish_sources: updatePublishSources(
|
|
||||||
currentSources,
|
|
||||||
sources,
|
|
||||||
enabled
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
const isMicrophoneEnabled = isSubsetOf([Source.Microphone], currentSources)
|
|
||||||
const isCameraEnabled = isSubsetOf([Source.Camera], currentSources)
|
|
||||||
const isScreenShareEnabled = isSubsetOf(
|
|
||||||
[Source.ScreenShare, Source.ScreenShareAudio],
|
|
||||||
currentSources
|
|
||||||
)
|
|
||||||
const isMutingEnabled = configuration?.everyone_can_mute ?? true
|
|
||||||
|
|
||||||
const saveAccessLevel = (newAccessLevel: ApiAccessLevel) => {
|
|
||||||
if (!user) return
|
|
||||||
patchUser({
|
|
||||||
userId: user.id,
|
|
||||||
user: { default_room_access_level: newAccessLevel },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TabPanel padding={'md'} flex id={id}>
|
|
||||||
<H lvl={2}>{t('heading')}</H>
|
|
||||||
<Text variant="note" margin={'md'}>
|
|
||||||
{t('description')}
|
|
||||||
</Text>
|
|
||||||
<RACSeparator
|
|
||||||
className={css({
|
|
||||||
border: 'none',
|
|
||||||
height: '1px',
|
|
||||||
width: '100%',
|
|
||||||
flexShrink: 0,
|
|
||||||
background: 'greyscale.250',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<H
|
|
||||||
lvl={3}
|
|
||||||
variant={'h2'}
|
|
||||||
className={css({
|
|
||||||
fontWeight: 500,
|
|
||||||
})}
|
|
||||||
margin="sm"
|
|
||||||
>
|
|
||||||
{tAdmin('moderation.title')}
|
|
||||||
</H>
|
|
||||||
<Text
|
|
||||||
variant="note"
|
|
||||||
wrap="balance"
|
|
||||||
className={css({
|
|
||||||
textStyle: 'sm',
|
|
||||||
})}
|
|
||||||
margin={'md'}
|
|
||||||
>
|
|
||||||
{tAdmin('moderation.description')}
|
|
||||||
</Text>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tAdmin('moderation.microphone.label')}
|
|
||||||
isSelected={isMicrophoneEnabled}
|
|
||||||
onChange={(enabled) => updateSource([Source.Microphone], enabled)}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tAdmin('moderation.camera.label')}
|
|
||||||
isSelected={isCameraEnabled}
|
|
||||||
onChange={(enabled) => updateSource([Source.Camera], enabled)}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tAdmin('moderation.screenshare.label')}
|
|
||||||
isSelected={isScreenShareEnabled}
|
|
||||||
onChange={(enabled) =>
|
|
||||||
updateSource([Source.ScreenShare, Source.ScreenShareAudio], enabled)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tAdmin('moderation.mute.label')}
|
|
||||||
isSelected={isMutingEnabled}
|
|
||||||
onChange={(enabled) =>
|
|
||||||
saveConfiguration({ ...configuration, everyone_can_mute: enabled })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<RACSeparator
|
|
||||||
className={css({
|
|
||||||
border: 'none',
|
|
||||||
height: '1px',
|
|
||||||
width: '100%',
|
|
||||||
flexShrink: 0,
|
|
||||||
marginY: '1rem',
|
|
||||||
background: 'greyscale.250',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<H
|
|
||||||
lvl={3}
|
|
||||||
variant={'h2'}
|
|
||||||
className={css({
|
|
||||||
fontWeight: 500,
|
|
||||||
})}
|
|
||||||
margin="sm"
|
|
||||||
>
|
|
||||||
{tAdmin('access.title')}
|
|
||||||
</H>
|
|
||||||
<Field
|
|
||||||
type="radioGroup"
|
|
||||||
label={tAdmin('access.type')}
|
|
||||||
value={accessLevel}
|
|
||||||
labelProps={{
|
|
||||||
className: css({
|
|
||||||
fontSize: '1rem',
|
|
||||||
paddingBottom: '1rem',
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
onChange={(value) => saveAccessLevel(value as ApiAccessLevel)}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
value: ApiAccessLevel.PUBLIC,
|
|
||||||
label: tAdmin('access.levels.public.label'),
|
|
||||||
description: tAdmin('access.levels.public.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ApiAccessLevel.TRUSTED,
|
|
||||||
label: tAdmin('access.levels.trusted.label'),
|
|
||||||
description: tAdmin('access.levels.trusted.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ApiAccessLevel.RESTRICTED,
|
|
||||||
label: tAdmin('access.levels.restricted.label'),
|
|
||||||
description: tAdmin('access.levels.restricted.description'),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -179,11 +179,6 @@
|
|||||||
"notifications": "Benachrichtigungen",
|
"notifications": "Benachrichtigungen",
|
||||||
"accessibility": "Barrierefreiheit",
|
"accessibility": "Barrierefreiheit",
|
||||||
"transcription": "Transkription",
|
"transcription": "Transkription",
|
||||||
"shortcuts": "Tastenkürzel",
|
"shortcuts": "Tastenkürzel"
|
||||||
"rooms": "Räume"
|
|
||||||
},
|
|
||||||
"roomDefaults": {
|
|
||||||
"heading": "Standardeinstellungen für Räume",
|
|
||||||
"description": "Wählen Sie die Einstellungen, die standardmäßig auf neue von Ihnen erstellte Räume angewendet werden. Sie können sie für jedes Meeting weiterhin in den Moderationseinstellungen ändern."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,11 +179,6 @@
|
|||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"accessibility": "Accessibility",
|
"accessibility": "Accessibility",
|
||||||
"transcription": "Transcription",
|
"transcription": "Transcription",
|
||||||
"shortcuts": "Shortcuts",
|
"shortcuts": "Shortcuts"
|
||||||
"rooms": "Rooms"
|
|
||||||
},
|
|
||||||
"roomDefaults": {
|
|
||||||
"heading": "Default room settings",
|
|
||||||
"description": "Choose the settings applied by default to the new rooms you create. You can still change them for each meeting from the host settings."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,11 +179,6 @@
|
|||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"accessibility": "Accessibilité",
|
"accessibility": "Accessibilité",
|
||||||
"transcription": "Transcription",
|
"transcription": "Transcription",
|
||||||
"shortcuts": "Raccourcis",
|
"shortcuts": "Raccourcis"
|
||||||
"rooms": "Réunions"
|
|
||||||
},
|
|
||||||
"roomDefaults": {
|
|
||||||
"heading": "Paramètres par défaut des réunions",
|
|
||||||
"description": "Choisissez les paramètres appliqués par défaut aux nouvelles réunions que vous créez. Vous pourrez toujours les modifier pour chaque réunion depuis les paramètres d’administration."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,11 +179,6 @@
|
|||||||
"notifications": "Meldingen",
|
"notifications": "Meldingen",
|
||||||
"accessibility": "Toegankelijkheid",
|
"accessibility": "Toegankelijkheid",
|
||||||
"transcription": "Transcriptie",
|
"transcription": "Transcriptie",
|
||||||
"shortcuts": "Sneltoetsen",
|
"shortcuts": "Sneltoetsen"
|
||||||
"rooms": "Vergaderingen"
|
|
||||||
},
|
|
||||||
"roomDefaults": {
|
|
||||||
"heading": "Standaardinstellingen voor vergaderingen",
|
|
||||||
"description": "Kies de instellingen die standaard worden toegepast op nieuwe vergaderingen die u aanmaakt. U kunt ze voor elke vergadering nog steeds wijzigen via de hostinstellingen."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,9 +143,13 @@ def test_media_info_ignores_empty_stream_entry(monkeypatch: pytest.MonkeyPatch)
|
|||||||
|
|
||||||
def test_extract_audio_from_video():
|
def test_extract_audio_from_video():
|
||||||
"""Test that extract_audio_from_video can extract audio from a video file."""
|
"""Test that extract_audio_from_video can extract audio from a video file."""
|
||||||
path = extract_audio_from_media(MEDIA_INFO_SAMPLE_VISIO)
|
path = None
|
||||||
# A bit of cleanup logic since this is not a generator
|
# A bit of cleanup logic since this is not a generator
|
||||||
try:
|
try:
|
||||||
|
path = extract_audio_from_media(MEDIA_INFO_SAMPLE_VISIO)
|
||||||
assert path.name.endswith(".m4a")
|
assert path.name.endswith(".m4a")
|
||||||
|
except Exception as e:
|
||||||
|
pytest.fail(f"Failed to extract audio from video: {e}")
|
||||||
finally:
|
finally:
|
||||||
path.unlink(missing_ok=True)
|
if path and path.exists():
|
||||||
|
path.unlink()
|
||||||
|
|||||||
Reference in New Issue
Block a user