mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-13 12:17:24 +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) introduce an "unauthenticated" participant badge
|
||||
- ✨(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
|
||||
|
||||
@@ -31,7 +29,7 @@ and this project adheres to
|
||||
- 💄(frontend) improve participant name rendering in the list
|
||||
- 🚚(backend) rename TelephonyService to SIPManagement
|
||||
|
||||
### Fixed
|
||||
## Fixed
|
||||
|
||||
- 🐛(transcription) fix silent bug in speaker assignment
|
||||
- 🐛(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
|
||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||
- 🚸(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
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql \
|
||||
env.d/development/summary \
|
||||
env.d/development/kube-secret \
|
||||
env.d/development/multi_user_transcriber \
|
||||
@@ -288,9 +287,6 @@ env.d/development/common:
|
||||
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:
|
||||
cp -n env.d/development/summary.dist env.d/development/summary
|
||||
|
||||
|
||||
+10
-34
@@ -147,7 +147,7 @@ services:
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- keycloak
|
||||
- dex
|
||||
- app-dev
|
||||
networks:
|
||||
- resource-server
|
||||
@@ -187,40 +187,16 @@ services:
|
||||
volumes:
|
||||
- ".:/app"
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
# OIDC provider for the development stack. Dex uses in-memory storage, so it
|
||||
# needs no database and no volume: restarting it rotates the signing keys and
|
||||
# drops every active session, which is fine locally.
|
||||
dex:
|
||||
image: dexidp/dex:v2.45.1
|
||||
command: ["dex", "serve", "/etc/dex/config.yaml"]
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
command:
|
||||
- start-dev
|
||||
- --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
|
||||
- ./docker/auth/dex.yaml:/etc/dex/config.yaml:ro
|
||||
expose:
|
||||
- "5556"
|
||||
|
||||
livekit:
|
||||
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 / {
|
||||
proxy_pass http://keycloak:8080;
|
||||
proxy_pass http://dex:5556;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
version: '3'
|
||||
|
||||
# You can add any necessary service here that will join the same docker network
|
||||
# sharing keycloak. Services added to the 'meet_resource-server' network will be
|
||||
# able to communicate with keycloak and the backend on that network.
|
||||
# sharing the OIDC provider. Services added to the 'meet_resource-server'
|
||||
# network will be able to communicate with dex (through nginx) and the backend
|
||||
# on that network.
|
||||
services:
|
||||
# busybox service is only used for testing purposes. It provides curl to test
|
||||
# connectivity to the backend and keycloak services. Replace this with your
|
||||
# relevant application services that need to communicate with keycloak.
|
||||
# connectivity to the backend and the OIDC provider. Replace this with your
|
||||
# relevant application services that need to communicate with them.
|
||||
busybox:
|
||||
image: alpine:latest
|
||||
privileged: true
|
||||
|
||||
@@ -71,8 +71,12 @@ $ make bootstrap FLUSH_ARGS='--no-input'
|
||||
|
||||
2. Access the project:
|
||||
- The frontend is available at [http://localhost:3000](http://localhost:3000) with the default credentials:
|
||||
- username: meet
|
||||
- email: meet@meet.world
|
||||
- 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -31,24 +31,35 @@ MEDIA_BASE_URL=http://localhost:3000
|
||||
FILE_UPLOAD_ENABLED=True
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token/introspect
|
||||
OIDC_OP_URL=http://localhost:8083/realms/meet
|
||||
# Provider is dex (docker/auth/dex.yaml), served behind nginx on port 8083.
|
||||
# Endpoints reached by the browser use localhost, the ones called server-side
|
||||
# by the backend use the nginx service name.
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/dex/keys
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/dex/auth
|
||||
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_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
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_FAILURE=http://localhost:3000
|
||||
LOGOUT_REDIRECT_URL=http://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_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(),
|
||||
"resource": {
|
||||
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
},
|
||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
|
||||
@@ -31,28 +31,9 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"id",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"timezone",
|
||||
"language",
|
||||
"default_room_access_level",
|
||||
"default_room_configuration",
|
||||
]
|
||||
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
|
||||
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):
|
||||
"""Serialize users with limited fields."""
|
||||
|
||||
@@ -308,27 +308,8 @@ class RoomViewSet(
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created room.
|
||||
|
||||
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)
|
||||
"""Set the current user as owner of the newly created room."""
|
||||
room = serializer.save()
|
||||
models.ResourceAccess.objects.create(
|
||||
resource=room,
|
||||
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,
|
||||
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(
|
||||
_("device"),
|
||||
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
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
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
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Room, RoomAccessLevel
|
||||
from ...models import Room
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -110,205 +109,3 @@ def test_api_rooms_create_authenticated_existing_slug():
|
||||
|
||||
assert response.status_code == 400
|
||||
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),
|
||||
"user": {
|
||||
"default_room_access_level": None,
|
||||
"default_room_configuration": {},
|
||||
"id": str(other_user_access.user.id),
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
@@ -468,8 +466,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": {
|
||||
"default_room_access_level": None,
|
||||
"default_room_configuration": {},
|
||||
"id": str(user_access.user.id),
|
||||
"email": user_access.user.email,
|
||||
"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.json() == {
|
||||
"default_room_access_level": None,
|
||||
"default_room_configuration": {},
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"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 { useQuery } from '@tanstack/react-query'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import type { Track } from 'livekit-client'
|
||||
type Source = Track.Source
|
||||
|
||||
@@ -50,9 +49,6 @@ export interface ApiConfig {
|
||||
international_phone_number?: string
|
||||
default_country?: string
|
||||
}
|
||||
resource?: {
|
||||
default_access_level?: ApiAccessLevel
|
||||
}
|
||||
manifest_link?: string
|
||||
livekit: {
|
||||
url: string
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { BackendLanguage } from '@/utils/languages'
|
||||
import type {
|
||||
ApiAccessLevel,
|
||||
RoomConfiguration,
|
||||
} from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export type ApiUser = {
|
||||
id: string
|
||||
@@ -11,6 +7,4 @@ export type ApiUser = {
|
||||
last_name: string
|
||||
language: BackendLanguage
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
||||
|
||||
const MIN_HEIGHT = 130
|
||||
const MIN_WIDTH = 140
|
||||
@@ -10,6 +11,72 @@ const MIN_VISIBLE_TILES = 1
|
||||
const ASPECT_RATIO = 16 / 10
|
||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||
|
||||
type CarouselOrientation = 'vertical' | 'horizontal'
|
||||
|
||||
interface CarouselLayoutState {
|
||||
orientation: CarouselOrientation
|
||||
maxVisibleTiles: number
|
||||
}
|
||||
|
||||
interface CarouselLayoutObserverProps {
|
||||
asideEl: React.RefObject<HTMLDivElement>
|
||||
orientation?: CarouselOrientation
|
||||
onLayoutChange: (layout: CarouselLayoutState) => void
|
||||
}
|
||||
|
||||
const CarouselLayoutObserver = ({
|
||||
orientation,
|
||||
asideEl,
|
||||
onLayoutChange,
|
||||
}: CarouselLayoutObserverProps) => {
|
||||
const { width, height } = useSize(asideEl)
|
||||
|
||||
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
||||
// container size hovers around a breakpoint. A ref (not state) because
|
||||
// updating it must not trigger a re-render.
|
||||
const prevTilesRef = React.useRef(0)
|
||||
|
||||
const carouselOrientation: CarouselOrientation =
|
||||
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
||||
|
||||
const tileSpan =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||
|
||||
const scrollBarWidth = getScrollBarWidth()
|
||||
|
||||
const availableSpan =
|
||||
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
||||
|
||||
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
||||
|
||||
let maxVisibleTiles: number
|
||||
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
||||
// Within the dead zone: keep the previous count.
|
||||
maxVisibleTiles = Math.round(prevTilesRef.current)
|
||||
} else {
|
||||
maxVisibleTiles = Math.round(tilesThatFit)
|
||||
prevTilesRef.current = tilesThatFit
|
||||
}
|
||||
|
||||
// Apply cosmetic layout output straight to the DOM.
|
||||
useLayoutEffect(() => {
|
||||
const el = asideEl.current
|
||||
if (!el) return
|
||||
el.dataset.lkOrientation = carouselOrientation
|
||||
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
||||
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
||||
|
||||
// Report upward only what the parent actually needs for
|
||||
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
||||
useEffect(() => {
|
||||
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
||||
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
@@ -40,52 +107,42 @@ export function CarouselLayout({
|
||||
...props
|
||||
}: CarouselLayoutProps) {
|
||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||
const [prevTiles, setPrevTiles] = React.useState(0)
|
||||
const { width, height } = useSize(asideEl)
|
||||
const carouselOrientation = orientation
|
||||
? orientation
|
||||
: height >= width
|
||||
? 'vertical'
|
||||
: 'horizontal'
|
||||
|
||||
const tileSpan =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||
const scrollBarWidth = getScrollBarWidth()
|
||||
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
||||
orientation: orientation ?? 'vertical',
|
||||
maxVisibleTiles: MIN_VISIBLE_TILES,
|
||||
})
|
||||
|
||||
const tilesThatFit =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||
// Stable callback + identity check: the parent only re-renders when the
|
||||
// derived layout genuinely changed, not on every resize tick.
|
||||
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
||||
setLayout((prev) =>
|
||||
prev.orientation === next.orientation &&
|
||||
prev.maxVisibleTiles === next.maxVisibleTiles
|
||||
? prev
|
||||
: next
|
||||
)
|
||||
}, [])
|
||||
|
||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
||||
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
||||
maxVisibleTiles = Math.round(prevTiles)
|
||||
} else if (prevTiles !== tilesThatFit) {
|
||||
setPrevTiles(tilesThatFit)
|
||||
}
|
||||
|
||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (asideEl.current) {
|
||||
asideEl.current.dataset.lkOrientation = carouselOrientation
|
||||
asideEl.current.style.setProperty(
|
||||
'--lk-max-visible-tiles',
|
||||
maxVisibleTiles.toString()
|
||||
)
|
||||
}
|
||||
}, [maxVisibleTiles, carouselOrientation])
|
||||
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
||||
|
||||
return (
|
||||
<aside
|
||||
key={carouselOrientation}
|
||||
className="lk-carousel"
|
||||
ref={asideEl}
|
||||
{...props}
|
||||
>
|
||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||
</aside>
|
||||
<>
|
||||
<CarouselLayoutObserver
|
||||
asideEl={asideEl}
|
||||
orientation={orientation}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
/>
|
||||
{/* `key` intentionally remounts the container when orientation flips, */}
|
||||
{/* which resets scroll position and re-runs the observer measurement. */}
|
||||
<aside
|
||||
key={layout.orientation}
|
||||
className="lk-carousel"
|
||||
ref={asideEl}
|
||||
{...props}
|
||||
>
|
||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,37 @@ import { mergeProps } from '@/utils/mergeProps'
|
||||
import { PaginationIndicator } from './PaginationIndicator'
|
||||
import { useGridLayout } from '../hooks/useGridLayout'
|
||||
import { PaginationControl } from './PaginationControl'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||
|
||||
interface GridLayoutObserverProps {
|
||||
gridEl: React.RefObject<HTMLDivElement>
|
||||
trackCount: number
|
||||
onMaxTilesChange: (maxTiles: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless component that runs the layout calculation in isolation and
|
||||
* reports the resulting tile capacity upward.
|
||||
*
|
||||
* `useGridLayout` re-renders its host on every layout recalculation
|
||||
* (e.g. container resizes). Rendering it in a null child means only this
|
||||
* component churns; the parent `GridLayout` re-renders solely when
|
||||
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
||||
*/
|
||||
const GridLayoutObserver = ({
|
||||
gridEl,
|
||||
trackCount,
|
||||
onMaxTilesChange,
|
||||
}: GridLayoutObserverProps) => {
|
||||
const { layout } = useGridLayout(gridEl, trackCount)
|
||||
useEffect(() => {
|
||||
onMaxTilesChange(layout.maxTiles)
|
||||
}, [onMaxTilesChange, layout.maxTiles])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface GridLayoutProps
|
||||
extends
|
||||
@@ -38,15 +67,14 @@ export interface GridLayoutProps
|
||||
* @public
|
||||
*/
|
||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
const gridEl = React.createRef<HTMLDivElement>()
|
||||
const gridEl = useRef<HTMLDivElement>(null)
|
||||
const [maxTiles, setMaxTiles] = useState(1)
|
||||
|
||||
const elementProps = React.useMemo(
|
||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||
[props]
|
||||
)
|
||||
|
||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
const pagination = usePagination(maxTiles, tracks)
|
||||
useSpeakerPromotionTrigger(pagination.tracks)
|
||||
|
||||
useSwipe(gridEl, {
|
||||
@@ -60,8 +88,13 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
data-lk-pagination={pagination.totalPageCount > 1}
|
||||
{...elementProps}
|
||||
>
|
||||
<GridLayoutObserver
|
||||
gridEl={gridEl}
|
||||
trackCount={tracks.length}
|
||||
onMaxTilesChange={setMaxTiles}
|
||||
/>
|
||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||
{tracks.length > layout.maxTiles && (
|
||||
{tracks.length > maxTiles && (
|
||||
<>
|
||||
<PaginationIndicator
|
||||
totalPageCount={pagination.totalPageCount}
|
||||
|
||||
@@ -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 { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { ConnectionObserver } from '../components/ConnectionObserver'
|
||||
import { MediaStateObserver } from '../components/MediaStateObserver'
|
||||
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
||||
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
@@ -64,7 +63,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<>
|
||||
<RoomMetadataSynchronizer />
|
||||
<ConnectionObserver />
|
||||
<MediaStateObserver />
|
||||
<ChatProvider />
|
||||
<VideoResolutionSubscription />
|
||||
<div
|
||||
|
||||
@@ -2,12 +2,9 @@ export class CallbackIdHandler {
|
||||
private readonly storageKey = 'popup_callback_id'
|
||||
|
||||
private generateId(): string {
|
||||
// The id is the only thing guarding /rooms/creation-callback/, which is
|
||||
// unauthenticated, so it comes from the CSPRNG rather than Math.random.
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
||||
''
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +1,22 @@
|
||||
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 { 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 { LoginButton } from '@/components/LoginButton'
|
||||
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'>
|
||||
|
||||
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) => {
|
||||
const { t, i18n } = useTranslation('settings')
|
||||
const { user, isLoggedIn } = useUser()
|
||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||
|
||||
const dialogEl = useRef<HTMLDivElement>(null)
|
||||
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
||||
|
||||
const userDisplay =
|
||||
user?.full_name && user?.email
|
||||
? `${user.full_name} (${user.email})`
|
||||
: user?.email
|
||||
|
||||
const generalContent = (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minWidth: '360px',
|
||||
})}
|
||||
>
|
||||
return (
|
||||
<Dialog title={t('dialog.heading')} {...props}>
|
||||
<H lvl={2}>{t('account.heading')}</H>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
@@ -104,56 +47,6 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"accessibility": "Barrierefreiheit",
|
||||
"transcription": "Transkription",
|
||||
"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."
|
||||
"shortcuts": "Tastenkürzel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,11 +179,6 @@
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibility",
|
||||
"transcription": "Transcription",
|
||||
"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."
|
||||
"shortcuts": "Shortcuts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,11 +179,6 @@
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibilité",
|
||||
"transcription": "Transcription",
|
||||
"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."
|
||||
"shortcuts": "Raccourcis"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,11 +179,6 @@
|
||||
"notifications": "Meldingen",
|
||||
"accessibility": "Toegankelijkheid",
|
||||
"transcription": "Transcriptie",
|
||||
"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."
|
||||
"shortcuts": "Sneltoetsen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +143,13 @@ def test_media_info_ignores_empty_stream_entry(monkeypatch: pytest.MonkeyPatch)
|
||||
|
||||
def test_extract_audio_from_video():
|
||||
"""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
|
||||
try:
|
||||
path = extract_audio_from_media(MEDIA_INFO_SAMPLE_VISIO)
|
||||
assert path.name.endswith(".m4a")
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed to extract audio from video: {e}")
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
if path and path.exists():
|
||||
path.unlink()
|
||||
|
||||
Reference in New Issue
Block a user