Compare commits

...

38 Commits

Author SHA1 Message Date
lebaudantoine 932f400a2e wip adapt lobby to be functional in an iframe 2026-08-03 14:20:56 +02:00
lebaudantoine a3f22e0a4f fixup! wip use another scheme for LiveKit token auth 2026-08-03 11:15:46 +02:00
lebaudantoine 0a879a96fd wip handle virtual background loading in an iframe context 2026-08-02 23:18:51 +02:00
lebaudantoine ed7fa7312a (frontend) alternative auth without relying on sameSite cookie 2026-08-02 19:03:16 +02:00
lebaudantoine 2f948fd53a wip use another scheme for LiveKit token auth 2026-08-02 18:59:02 +02:00
lebaudantoine e701a89036 (backend) wip introduce a token exchange endpoint 2026-08-01 15:52:37 +02:00
lebaudantoine 7fbcbc89ed 🧑‍💻(devx) prototype OrbStack as local Kubernetes provider on macOS
Add a prototype setup that uses OrbStack as the local Kubernetes
provider on macOS, aiming to save a few GB of RAM compared to the
current stack.

The script has been tested locally but was mostly built through vibe
coding, so it has not been thoroughly reviewed yet.

Follow-ups to look into:

* Simplify the ingress and CoreDNS setup if possible.
* Wire up a way to run tests and lint against the Tilt stack.
2026-07-31 14:18:37 +02:00
lebaudantoine 89f8480e0b 🚸(frontend) show two initials in the Avatar when possible
Display two initials in the Avatar whenever the participant's name
allows it, instead of a single letter.

A single initial makes it too hard to distinguish participants when
their cameras are off, especially in larger
2026-07-29 00:00:35 +02:00
lebaudantoine d9bf6efa2a 💄(frontend) improve participant name rendering in the list
Rework how the participant name is displayed in the participant
list to show as much of the name as possible before truncating.

When the name has to be truncated, add a tooltip so users can hover
to see the full name.

Requested by users.
2026-07-29 00:00:35 +02:00
lebaudantoine 4cb7412194 (frontend) introduce an "unauthenticated" participant badge
Add a visual badge on participants who are not authenticated, so it
is immediately clear who could be an anonymous participant. This is
a small but explicit security signal in the participant list.

Beyond that, the badge also plays a functional role: since only
authenticated participants can be promoted or demoted, the badge
helps users see at a glance who is eligible for a role change.
2026-07-29 00:00:35 +02:00
lebaudantoine e8df597055 (frontend) notify user when their meeting role changes
Show a notification to the user whenever their role in the meeting
changes, so they immediately see when they have been promoted or
demoted.
2026-07-29 00:00:35 +02:00
lebaudantoine 05dfcd11ca 🐛(frontend) fall back to user.full_name on request-entry
Since the username refactoring, the username in the store could be
undefined when the join input was pre-filled from user.full_name,
because no keystroke was needed to populate the store.

This led to a 400 error on the request-entry endpoint whenever the
user joined without editing the pre-filled name.

Fall back to user.full_name when the store username is missing, so
the endpoint always receives a value.

Acknowledged as a somewhat wobbly fix, but ships as-is until the
underlying flow is reworked.
2026-07-29 00:00:35 +02:00
lebaudantoine b018832fcf (frontend) close admin side panel when the user is demoted
Listen to role changes in the admin panel and close the side panel
if the current user is demoted while it is open. Without this,
unprivileged users could still see the admin side panel until they
closed it manually.

I checked the other features that could be affected by hot role
changes; this was the only one still exposing admin-only UI after a
demotion. Everything else already handles live permission updates
correctly.
2026-07-29 00:00:35 +02:00
lebaudantoine a269160f6f (frontend) allow promoting authenticated participants
Introduce a new feature that lets a user promote one of the
authenticated participants of the meeting to a role with additional
privileges.

Known limitations:

* Only authenticated participants can be promoted, but there is no
  visual indicator yet distinguishing authenticated from anonymous
  participants. This will be added in a follow-up commit.
* The resource_access data fetched in the initial API call becomes
  stale after a promotion. It is not currently used in the product,
  so this is not visible, but it should either be refreshed later
  or removed from the initial fetch.
* Demoting a promoted user turns them into a member, which is still
  a privileged role. This is a deliberate choice until we introduce
  finer-grained tuning of participant roles.
2026-07-29 00:00:35 +02:00
lebaudantoine c3afa84d9b ♻️(frontend) extract closeSidePanel action at the store level
Extract the logic that closes the side panel into a utility function
declared at the store module level, as recommended by Valtio.

This avoids re-creating the function on every render and prevents
extra re-renders in components that use it.
2026-07-29 00:00:35 +02:00
lebaudantoine 8c6752a29f 🐛(backend) allow any string as sub in the API serializer
The API serializer was too restrictive on the `sub` field, expecting
a UUID. This worked in our development and production setups because
our Keycloak is configured to emit UUID subs, but it broke for other
providers.

Per the OIDC spec and the DB model, `sub` can be any string. Align
the serializer with this and accept arbitrary string values.

Fixes #1525.
2026-07-29 00:00:35 +02:00
lebaudantoine 4c57432a03 💄(frontend) render Avatar initials in uppercase
Uppercase the initials rendered in the Avatar so their vertical
centering stays consistent.

With lowercase letters, the initials were slightly shifted toward
the bottom of the Avatar, which broke the alignment.
2026-07-29 00:00:35 +02:00
lebaudantoine 3b7bfd999c 🔥(frontend) remove leftover console.count call
Drop a stray console.count call that was accidentally committed in a
previous PR and had been left in the codebase.
2026-07-29 00:00:35 +02:00
lebaudantoine 7f386b2e2f ♻️(frontend) derive is_administrable from participant metadata
The is_administrable flag was previously read from the room API
response through the room serializer, giving the frontend static
information about the user's rights.

Refactor the frontend so it derives this flag from the participant
role carried in the participant metadata instead.

Two benefits:

* The flag now updates live along with the participant
  attributes/metadata, so role changes are reflected immediately.
* It removes the duplication between the API response and the
  metadata, which both used to determine the user's capabilities.
2026-07-29 00:00:35 +02:00
lebaudantoine c55d8235fd (frontend) add client for the update participant role endpoint
Add the frontend client that calls the update participant role
endpoint. Straightforward API call, no special handling.
2026-07-29 00:00:35 +02:00
lebaudantoine c7b23abd68 (backend) expose is_authenticated in the LiveKit token
Include the is_authenticated flag on the user in the LiveKit token
and participant metadata.

The frontend needs this information (used in the next commit) to
know whether it can offer to promote a user with access to the room
admin.
2026-07-29 00:00:35 +02:00
lebaudantoine 5a641a4366 ♻️(backend) pass the participant role in the LiveKit token
The backend previously passed an abstract is_admin_or_owner boolean
flag in the LiveKit token. That kept the frontend minimalistic and
saved it from having to handle role comparisons.

As we introduce more features that need to distinguish between the
room owner and admins, refactor the token to carry the role
directly. The frontend can then derive the relevant flags from a
richer piece of information.
2026-07-29 00:00:35 +02:00
lebaudantoine f043ad6f98 (backend) add endpoint to update a participant role during a meeting
Add an endpoint that allows updating a user's role while in a
meeting. The goal is to let users promote other connected
participants to admin or moderator, so the burden of administrating
a meeting can be shared.
2026-07-29 00:00:35 +02:00
lebaudantoine 1141c1cecd ️(backend) add permission class checking the user is in the call
Introduce a new permission class that verifies the caller making a
request is both authenticated and actually present in the call.

It will be used to gate actions that require the user to be live in
the room, for example:

* allowing someone in from the waiting room
* promoting another participant to a different role

More generally, this covers every action where, for security
reasons, we need to make sure the user is truly present in the call
and that someone is not reusing their cookie as an API key.
2026-07-29 00:00:35 +02:00
Arnaud Robin 33792b050a 📝(legal) update terms of service
Update terms of service content after legal review
2026-07-28 13:52:50 +02:00
leo f4569c64e5 🐛(backend) preserve recording metadata when updating room access
Updating room access rewrote the entire metadata payload, removing information
about active recordings. This caused the frontend to lose track of ongoing
recordings and could trigger 409 errors when attempting to start a new
recording.

Consolidate the duplicated metadata update logic into
`RoomManagement.update_metadata()` and preserve merge behavior instead of
overwriting the full metadata object.
2026-07-28 10:54:03 +02:00
snyk-bot 6a00d3d087 ⬆️(frontend) upgrade i18next from 26.3.2 to 26.3.4
Snyk has created this PR to upgrade i18next from 26.3.2 to 26.3.4.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-07-27 18:21:10 +02:00
Camille Moulin 2e975e2643 📝(metadata): Add publiccode.yml file
Recommended for Public Administration Open Source software projects.
See https://yml.publiccode.tools/

Signed-off-by: Camille Moulin <camille.moulin@numerique.gouv.fr>
2026-07-27 18:20:34 +02:00
lebaudantoine 4c63aa827f 📝(frontend) add changelog entry for PR #1510
Document in the CHANGELOG the set of changes shipped in PR #1510,
which groups the recent chat, layout and participant tile render
optimizations.
2026-07-24 18:31:47 +02:00
lebaudantoine 67e9bf2fef 🐛(frontend) reset chat state when the ChatProvider mounts
Reset the chat state on the first render of the ChatProvider, to
make sure no chat messages from a previous room leak into the new
one.

This covers SPA navigations where the user switches from one room
to another without a full page reload.
2026-07-24 18:31:47 +02:00
lebaudantoine 7124167947 🐛(frontend) fix pinnedTrackRef always evaluating to true
pinnedTrackRef was always truthy when evaluated in this
code path.
2026-07-24 18:31:47 +02:00
lebaudantoine 759388c72f ️(frontend) tripwire promotion of off-screen active speakers
Introduce a tripwire component that listens to
RoomEvent.ActiveSpeakersChanged imperatively and forces a single
re-render of its host only when an active speaker has none of their
tiles within the visible span (maxVisibleTiles). That re-render
re-runs useVisualStableUpdate, which reads live isSpeaking state
and performs the actual tile swap.

Speakers already visible are ignored, so this costs zero React work
in the common case.

This lets us drop the ActiveSpeakersChanged subscription from
useTracks in the StageLayout upstream (updateOnlyOn: []), which was
re-rendering the whole stage on every speaker change.
2026-07-24 18:31:47 +02:00
lebaudantoine a663b4dc76 ️(frontend) memoize the EffectsButton
Memoize the EffectsButton so it does not re-render on unrelated
parent updates when its props have not changed.
2026-07-24 18:31:47 +02:00
lebaudantoine 73aa162dc8 ️(frontend) reduce re-renders of the ParticipantTile focus overlay
Optimize the idle mouse handling and the FocusOverlay component so
they no longer trigger frequent re-renders of the ParticipantTile
focus.

State related to hover and focus is now scoped closer to where it
is used, keeping updates local instead of propagating up the tile.
2026-07-24 18:31:47 +02:00
lebaudantoine a3851842e9 🚚(frontend) extract ParticipantTile sub-components into their own files
Split the sub-components currently declared inside ParticipantTile
into dedicated files.

This makes the ParticipantTile file easier to read and lets each
sub-component be imported and reasoned about on its own.
2026-07-24 18:31:47 +02:00
lebaudantoine 77964c6a74 ♻️(frontend) harmonize participant name handling in ParticipantTile
Align how the participant name is retrieved and rendered inside the
ParticipantTile, so the different code paths use a single consistent
approach instead of a mix of ad hoc logic.
2026-07-24 18:31:47 +02:00
lebaudantoine 72b863e794 ️(frontend) memoize the Placeholder component
Turn the Placeholder into a pure leaf component and memoize it, so
it does not re-render on unrelated parent updates when its props
have not changed.
2026-07-24 18:31:47 +02:00
lebaudantoine 0e3c978af2 ️(frontend) size the Avatar with CSS instead of useSize
Stop using the useSize hook to compute the Avatar size in JS. Rely
on CSS to size the Avatar responsively instead.

This removes a set of unnecessary re-renders triggered by the
useSize subscription every time the container resized.
2026-07-24 18:31:47 +02:00
125 changed files with 4029 additions and 1013 deletions
+18 -8
View File
@@ -12,20 +12,30 @@ and this project adheres to
- ✨(summary) report exception type in failure analytics - ✨(summary) report exception type in failure analytics
- ✨(frontend) add configurable documentation menu item - ✨(frontend) add configurable documentation menu item
- ✨(frontend) allow promoting authenticated participants
- ✨(frontend) introduce an "unauthenticated" participant badge
### Changed
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.4
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
- ⚡️(frontend) limit unnecessary re-renders #1510
- 📝(legal) update terms of service
- 💄(frontend) render Avatar initials in uppercase
- 💄(frontend) improve participant name rendering in the list
## 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
- 🐛(summary) properly detect when failure webhook should be sent - 🐛(summary) properly detect when failure webhook should be sent
- 🐛(backend) preserve recording metadata when updating room access
### Changed - 🐛(backend) allow any string as sub in the API serializer
- 🐛(frontend) fall back to user.full_name on request-entry
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35 - 🚸(frontend) show two initials in the Avatar when possible
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
## [1.24.0] - 2026-07-21 ## [1.24.0] - 2026-07-21
+6
View File
@@ -389,6 +389,12 @@ build-k8s-cluster: \
./bin/start-kind.sh ./bin/start-kind.sh
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
build-k8s-cluster-orbstack: ## setup the kubernetes environment on OrbStack's built-in cluster (macOS)
build-k8s-cluster-orbstack: \
env.d/development/kube-secret
./bin/start-orbstack.sh
.PHONY: build-k8s-cluster-orbstack
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
+6
View File
@@ -1,5 +1,11 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location') load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject') load('ext://namespace', 'namespace_create', 'namespace_inject')
# OrbStack's built-in cluster (macOS) is a supported alternative to kind.
# Recent Tilt versions (>= 0.33) detect it as a local dev cluster; this is
# a no-op for kind and a safety net for older Tilt versions.
allow_k8s_contexts('orbstack')
namespace_create('meet') namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak') DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
#
# Bootstrap the local dev environment on OrbStack's built-in Kubernetes
# cluster (macOS) instead of kind.
#
# This replicates what bin/start-kind.sh (numerique-gouv/tools
# kind/create_cluster.sh) provides, minus what OrbStack makes unnecessary:
# - no kind cluster: OrbStack ships a lightweight single-node cluster
# - no local registry (kind-registry): OrbStack's cluster shares the
# Docker image store, so images built by Tilt are directly visible
# to pods. Tilt detects the "orbstack" context as a local cluster
# and skips pushing images entirely.
#
# Requirements: OrbStack (with Kubernetes enabled), kubectl, mkcert, curl.
set -o errexit
APPLICATION=${1:-meet}
CONTEXT="orbstack"
echo "0. Check OrbStack Kubernetes is available"
if ! command -v mkcert >/dev/null 2>&1; then
echo "❌ mkcert is not installed. Install it first: brew install mkcert"
exit 1
fi
if ! kubectl config get-contexts -o name | grep -qx "${CONTEXT}"; then
echo "Context '${CONTEXT}' not found. Trying to start OrbStack Kubernetes..."
if command -v orb >/dev/null 2>&1; then
orb start k8s
else
echo "❌ Enable Kubernetes in OrbStack (Settings > Kubernetes) and retry."
exit 1
fi
fi
kubectl config use-context "${CONTEXT}"
echo "0b. Check ports 80/443 are free on localhost"
# OrbStack forwards LoadBalancer service ports to 127.0.0.1. If the kind
# cluster is still running, its docker proxy already holds 80/443.
# Skip the check if ingress-nginx is already installed here: in that case
# the listener on 80/443 is our own LoadBalancer.
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
for port in 80 443; do
if lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
echo "❌ Port ${port} is already in use on the host."
echo " If the kind cluster is running, delete it first:"
echo " kind delete cluster --name suite"
exit 1
fi
done
fi
echo "1. Create ca"
CURRENT_DIR=$(pwd)
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd "${CURRENT_DIR}"
echo "2. Install ingress-nginx (cloud provider: LoadBalancer service)"
# OrbStack exposes LoadBalancer services on 127.0.0.1, so the cloud
# manifest replaces kind's hostPort-based deploy. Every sub-step below is
# guarded individually so the script is safe to re-run after a partial
# failure (unlike the upstream kind script, which guards the whole block
# on namespace existence).
# Make sure no stale registry configmap tells Tilt to push to localhost:5001
# (there is no registry on OrbStack).
kubectl -n kube-public delete configmap local-registry-hosting --ignore-not-found
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/cloud/deploy.yaml
fi
if ! kubectl -n ingress-nginx get deployment nginx-errors >/dev/null 2>&1; then
kubectl apply -n ingress-nginx -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/refs/heads/main/docs/examples/customization/custom-errors/custom-default-backend.yaml
fi
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem || echo ok
# The meet charts render Ingresses without ingressClassName. The kind
# provider manifest handles this via --watch-ingress-without-class=true;
# the cloud manifest does not, so add it here (otherwise: 404 everywhere).
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -q 'watch-ingress-without-class'; then
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--watch-ingress-without-class=true"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-backend-service=ingress-nginx/nginx-errors"}
]'
fi
if ! kubectl -n ingress-nginx get deployment nginx-errors -o jsonpath='{.spec.template.spec.containers[0].image}' | grep -q 'error-pages'; then
kubectl -n ingress-nginx patch deployment nginx-errors --type=json -p='[
{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value": "ghcr.io/tarampampam/error-pages:3.3.0"},
{"op": "add", "path": "/spec/template/spec/containers/0/env", "value": [{"name": "TEMPLATE_NAME", "value": "ghost"}, {"name": "SHOW_DETAILS", "value": "false"}, {"name": "SEND_SAME_HTTP_CODE", "value": "true"}]}
]'
fi
cat <<EOF | kubectl apply -n ingress-nginx -f -
apiVersion: v1
data:
allow-snippet-annotations: "true"
annotations-risk-level: Critical
custom-http-errors: 500,501,502,503,504
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
EOF
echo "2b. Wait for the ingress controller to be ready"
kubectl -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=180s
echo "3. Patch CoreDNS so in-cluster pods resolve *.127.0.0.1.nip.io to the ingress"
# nip.io resolves to 127.0.0.1, which inside a pod is the pod itself.
# Rewrite these names to the ingress-nginx service, like the kind setup does.
# Unlike kind, we amend OrbStack's existing Corefile instead of replacing it.
if ! kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' | grep -q '127\.0\.0\.1\.nip\.io'; then
kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' \
| awk '/forward \./ && !done { print " rewrite stop {"; print " name regex (.*).127.0.0.1.nip.io ingress-nginx-controller.ingress-nginx.svc.cluster.local answer auto"; print " }"; done=1 } { print }' \
>/tmp/Corefile.orbstack
kubectl -n kube-system create configmap coredns --from-file=Corefile=/tmp/Corefile.orbstack --dry-run=client -o yaml | kubectl apply -f -
kubectl -n kube-system rollout restart deployments/coredns
fi
if ! kubectl get ns "${APPLICATION}" >/dev/null 2>&1; then
echo "4. Setup namespace"
kubectl create ns "${APPLICATION}"
fi
kubectl config set-context --current --namespace="${APPLICATION}"
kubectl -n "${APPLICATION}" create secret generic mkcert --from-file=rootCA.pem="$(mkcert -CAROOT)/rootCA.pem" || echo ok
if ! kubectl get configmap certifi -n "${APPLICATION}" >/dev/null 2>&1; then
echo "5. Inject our custom CA in a configmap for certifi"
curl https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/cacert.pem
cat "$(mkcert -CAROOT)/rootCA.pem" >>/tmp/cacert.pem
kubectl -n "${APPLICATION}" create configmap certifi --from-file=cacert.pem=/tmp/cacert.pem
kubectl -n "${APPLICATION}" create secret generic certifi --from-file=/tmp/cacert.pem || echo ok
fi
echo "5b. Smoke test: the ingress chain answers on https://127.0.0.1"
# Before Tilt deploys the app this returns the styled 404 from the default
# backend — that still proves LB -> controller works. 000 means the
# LoadBalancer is not bound to localhost.
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 https://127.0.0.1/ || true)
if [ "${HTTP_CODE}" = "000" ]; then
echo "⚠️ Nothing answered on https://127.0.0.1 — check the LoadBalancer:"
echo " kubectl -n ingress-nginx get svc ingress-nginx-controller"
else
echo "✅ Ingress reachable (HTTP ${HTTP_CODE})"
fi
echo "6. Check pod readiness across all namespaces..."
sleep_interval=10
echo "Initial wait time: $((sleep_interval * 2)) seconds…"
sleep $((sleep_interval * 2))
check_pods_ready() {
local max_attempts=60 # Maximum number of attempts (10 minutes with 10s intervals)
local attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt/$max_attempts - Checking pod status..."
not_ready_count=$( kubectl get po -A --no-headers | grep -v -E "Running|Completed"| wc -l | tr -d ' ')
if [ "$not_ready_count" -eq 0 ]; then
echo "✅ All pods are ready!"
return 0
else
echo "$not_ready_count pod(s) still not ready. Waiting $sleep_interval seconds…"
sleep $sleep_interval
((attempt++))
fi
done
echo "❌ Timeout: Some pods are still not ready after 10 minutes"
echo "Final pod status:"
kubectl get po -A
return 1
}
if check_pods_ready; then
echo "🎉 Cluster is fully ready!"
else
echo "⚠️ Some pods may need manual intervention"
exit 1
fi
+21
View File
@@ -143,3 +143,24 @@ $ make start-tilt-keycloak
``` ```
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/). Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
### Alternative: OrbStack's built-in Kubernetes (macOS)
If you use [OrbStack](https://orbstack.dev/) on macOS, you can run the stack on its built-in Kubernetes cluster instead of kind. It uses noticeably less RAM (no nested kubeadm node container) and no local registry is needed: OrbStack's cluster shares the Docker image store, so Tilt uses images directly without pushing.
Enable Kubernetes in OrbStack (Settings > Kubernetes), then:
```shellscript
$ make build-k8s-cluster-orbstack
```
This installs ingress-nginx (exposed by OrbStack on `127.0.0.1:80/443`), the mkcert TLS certificates, and the CoreDNS rewrite for `*.127.0.0.1.nip.io`, then you start Tilt as usual:
```shellscript
$ make start-tilt-keycloak
```
Notes:
- Ports 80/443 must be free: delete the kind cluster first if you used it (`kind delete cluster --name suite`).
- If you "Reset Kubernetes" in OrbStack, re-run `make build-k8s-cluster-orbstack`.
- kind remains the reference setup (matches CI and lets you pin the Kubernetes version).
+52
View File
@@ -0,0 +1,52 @@
publiccodeYmlVersion: 0.5.0
name: LaSuite Meet
applicationSuite: LaSuite
url: https://github.com/suitenumerique/meet
releaseDate: 2026-07-22
platforms:
- web
organisation:
name: DINUM
uri: https://numerique.gouv.fr
fundedBy:
- name: Direction interministérielle du numérique (DINUM)
uri: https://www.numerique.gouv.fr
developmentStatus: stable
softwareType: standalone/web
intendedAudience:
countries:
- FR
description:
en:
localisedName: LaSuite Meet
shortDescription: "Open Source video conference solution, based on LiveKit"
longDescription: "Open Source video conference application, based on LiveKit,
Django and React. It is the official web video conference application of
French Ministries."
features:
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- Meeting recording
- Meeting transcription & Summary
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
legal:
license: MIT
maintenance:
type: internal
contacts:
- name: "Samuel Paccoud"
email: samuel.paccoud@numerique.gouv.fr
affiliation: DINUM
- name: "Antoine Lebaud"
email: antoine.lebaud.ext@numerique.gouv.fr
affiliation: DINUM
localisation:
localisationReady: true
availableLanguages:
- fr
- de
- en
- nl
+4 -4
View File
@@ -10,7 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"core-js": "3.49.0", "core-js": "3.49.0",
"i18next": "^26.3.2", "i18next": "^26.3.4",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1" "regenerator-runtime": "0.14.1"
}, },
@@ -9364,9 +9364,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.3.2", "version": "26.3.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.2.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
"integrity": "sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==", "integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
+1 -1
View File
@@ -27,7 +27,7 @@
}, },
"dependencies": { "dependencies": {
"core-js": "3.49.0", "core-js": "3.49.0",
"i18next": "26.3.2", "i18next": "26.3.4",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1" "regenerator-runtime": "0.14.1"
}, },
+1
View File
@@ -16,6 +16,7 @@ class FeatureFlag:
"file_upload": "FILE_UPLOAD_ENABLED", "file_upload": "FILE_UPLOAD_ENABLED",
"addons": "ADDONS_ENABLED", "addons": "ADDONS_ENABLED",
"application": "APPLICATION_ENABLED", "application": "APPLICATION_ENABLED",
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
} }
@classmethod @classmethod
+32
View File
@@ -6,6 +6,11 @@ from django.http import Http404
from rest_framework import permissions from rest_framework import permissions
from ..models import RoleChoices from ..models import RoleChoices
from ..services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
ACTION_FOR_METHOD_TO_PERMISSION = { ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"} "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
@@ -166,3 +171,30 @@ class CanMuteParticipant(permissions.BasePermission):
# LiveKit token scoped to this room # LiveKit token scoped to this room
return request.auth.video.room == str(obj.id) return request.auth.video.room == str(obj.id)
class IsPresentInMeeting(permissions.BasePermission):
"""Check that the requesting user is currently connected to the meeting.
The requester must be session-authenticated (their DB identity is needed
to check privileges); presence is verified against LiveKit using their
`sub` as participant identity. Fails closed on LiveKit errors.
"""
message = "You must be connected to the meeting to perform this action."
def has_object_permission(self, request, view, obj):
"""Verify the requester's identity is a participant of the room."""
user = request.user
if not user or not user.is_authenticated:
return False
try:
return ParticipantsManagement().check_if_in_meeting(
room_name=str(obj.pk), identity=str(user.sub)
)
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
+39 -5
View File
@@ -183,13 +183,11 @@ class RoomSerializer(serializers.ModelSerializer):
user=request.user, user=request.user,
username=username, username=username,
configuration=output["configuration"], configuration=output["configuration"],
is_admin_or_owner=is_admin_or_owner, role=role,
) )
else: else:
del output["pin_code"] del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
return output return output
@@ -275,6 +273,11 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data.""" """Validate request entry data."""
username = serializers.CharField(required=True) username = serializers.CharField(required=True)
participant_id = serializers.UUIDField(required=False, allow_null=True)
def validate_participant_id(self, value):
"""The id is a bearer credential: never trusted, only looked up."""
return str(value) if value else None
class ParticipantEntrySerializer(BaseValidationOnlySerializer): class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -299,8 +302,8 @@ class RoomInviteSerializer(serializers.Serializer):
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer): class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
"""Base serializer for participant management operations.""" """Base serializer for participant management operations."""
participant_identity = serializers.UUIDField( participant_identity = serializers.CharField(
help_text="LiveKit participant identity (UUID format)" help_text="LiveKit participant identity (matching the user's sub format)"
) )
@@ -312,6 +315,15 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
) )
class ParticipantRoleSerializer(BaseParticipantsManagementSerializer):
"""Validate an in-meeting role change (promotion/demotion) request."""
role = serializers.ChoiceField(
choices=[models.RoleChoices.MEMBER, models.RoleChoices.ADMIN],
help_text="Target role. Ownership cannot be granted this way.",
)
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"] TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
@@ -573,3 +585,25 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
# useless bad requests # useless bad requests
type = serializers.CharField(required=False, allow_null=True, allow_blank=True) type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
status = serializers.CharField(required=False, allow_null=True, allow_blank=True) status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
class TransitCodeSerializer(BaseValidationOnlySerializer):
"""Validate the single-use transit code sent to the exchange endpoint."""
# todo if I can pass the max length directly to the char field
code = serializers.CharField(max_length=255, trim_whitespace=True)
def validate_code(self, value):
"""Reject codes whose length cannot match a generated one.
`secrets.token_urlsafe(nbytes)` produces (4 * nbytes + 2) // 3
url-safe characters. Checking the length against the configured
TRANSIT_CODE_NBYTES makes malformed codes fail fast with a 400,
before any cache lookup.
"""
expected_length = (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
if len(value) != expected_length:
raise serializers.ValidationError("Invalid transit code format.")
return value
+27 -11
View File
@@ -1,11 +1,11 @@
"""Throttling modules for the API.""" """Throttling modules for the API."""
from django.conf import settings
from lasuite.drf.throttling import MonitoredThrottleMixin from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from sentry_sdk import capture_message from sentry_sdk import capture_message
from . import serializers
def sentry_monitoring_throttle_failure(message): def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues.""" """Log when a failure occurs to detect rate limiting issues."""
@@ -42,13 +42,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
def get_cache_key(self, request, view): def get_cache_key(self, request, view):
"""Use the lobby participant cookie ID as the throttle cache key. """Use the lobby participant cookie ID as the throttle cache key.
Only throttle if a cookie is already set. If no cookie exists yet, Only throttle requests carrying a participant identifier. The
return None to skip throttling — the cookie will be set on the first identifier is returned by the first request-entry response and
response, and throttling will apply from the second request onward. echoed back by the client from the second request onward, which is
when throttling starts applying.
Keying on the cookie rather than the IP address prevents penalising Keying on the identifier rather than the IP address prevents
multiple users behind the same NAT/proxy, and is consistent with how penalising multiple users behind the same NAT/proxy, and is
LobbyService identifies participants. consistent with how the lobby identifies participants.
Note: as per DRF documentation, application-level throttling is not a Note: as per DRF documentation, application-level throttling is not a
security measure against brute-force or DoS attacks. This throttle exists security measure against brute-force or DoS attacks. This throttle exists
@@ -58,10 +59,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
if request.user and request.user.is_authenticated: if request.user and request.user.is_authenticated:
return None # Only throttle unauthenticated requests. return None # Only throttle unauthenticated requests.
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME) serializer = serializers.RequestEntrySerializer(data=request.data)
if not serializer.is_valid():
return None
if participant_id is None: participant_id = serializer.validated_data.get("participant_id")
return None # No throttling for cookieless requests
if not participant_id:
return None # No throttling for unidentified requests
return self.cache_format % { return self.cache_format % {
"scope": self.scope, "scope": self.scope,
@@ -73,3 +78,14 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback""" """Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback" scope = "creation_callback"
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous transit code exchange attempts.
Abuse mitigation only, not a security boundary: DRF throttling is
best-effort. The security of the exchange rests on the codes'
entropy and single use.
"""
scope = "exchange_access_token"
+124 -4
View File
@@ -69,6 +69,7 @@ from core.recording.worker.mediator import (
WorkerServiceMediator, WorkerServiceMediator,
) )
from core.services.invitation import InvitationService from core.services.invitation import InvitationService
from core.services.jwt_token import JwtTokenService
from core.services.livekit_events import ( from core.services.livekit_events import (
LiveKitEventsService, LiveKitEventsService,
LiveKitWebhookError, LiveKitWebhookError,
@@ -88,7 +89,12 @@ from core.services.room_management import (
RoomManagementException, RoomManagementException,
RoomNotFoundException, RoomNotFoundException,
) )
from core.services.room_roles import (
RoomRoleError,
RoomRoleService,
)
from core.services.subtitle import SubtitleException, SubtitleService from core.services.subtitle import SubtitleException, SubtitleService
from core.services.transit_code import TransitCodeService
from core.tasks.file import process_file_deletion from core.tasks.file import process_file_deletion
from ..authentication.livekit import LiveKitTokenAuthentication from ..authentication.livekit import LiveKitTokenAuthentication
@@ -225,6 +231,76 @@ class UserViewSet(
self.serializer_class(request.user, context=context).data self.serializer_class(request.user, context=context).data
) )
@decorators.action(
detail=False,
methods=["post"],
url_path="exchange-access-token",
permission_classes=[],
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
)
@FeatureFlag.require("user_access_token")
def exchange_access_token(self, request):
"""Exchange a single-use transit code for a user access token.
The endpoint is unauthenticated: the transit code itself, an opaque
random string obtained through the external API and delivered to
the embedded frontend via a URL fragment, is the credential. Each
code can be exchanged exactly once (consuming it deletes it from
the cache); replaying a consumed code is denied and logged.
The issued JWT authenticates the user the code was minted for on
the whole core API, exactly like a session cookie would (similar
to lib-jitsi-meet's token authentication), and never appears in
any URL. Role-based permissions apply unchanged.
"""
serializer = serializers.TransitCodeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
code_data = TransitCodeService().consume_code(serializer.validated_data["code"])
if code_data is None:
logger.warning("Invalid, expired or already used transit code")
raise drf_exceptions.PermissionDenied(
"Invalid, expired or already used transit code."
)
# Re-check the user at exchange time so that a deactivation after
# the transit code was minted is taken into account.
try:
user = models.User.objects.get(id=code_data["user_id"], is_active=True)
except models.User.DoesNotExist as excpt:
raise drf_exceptions.PermissionDenied(
"This account can no longer access the application."
) from excpt
token_service = JwtTokenService(
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=settings.USER_ACCESS_TOKEN_ALG,
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
token_type=settings.USER_ACCESS_TOKEN_TYPE,
)
# todo - discuss wether it's the relevant scope
data = token_service.generate_jwt(
user,
"user:access",
{
"token_type": "user_access",
"client_id": code_data.get("client_id", "unknown"),
},
)
# Log for auditing
logger.info(
"User access token issued from transit code: user_id=%s, client_id=%s",
user.id,
code_data.get("client_id", "unknown"),
)
return drf_response.Response(data)
class RoomViewSet( class RoomViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
@@ -495,10 +571,7 @@ class RoomViewSet(
request=request, request=request,
**serializer.validated_data, **serializer.validated_data,
) )
response = drf_response.Response({**participant.to_dict(), "livekit": livekit}) return drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id)
return response
@decorators.action( @decorators.action(
detail=True, detail=True,
@@ -639,6 +712,53 @@ class RoomViewSet(
status=drf_status.HTTP_200_OK, status=drf_status.HTTP_200_OK,
) )
@decorators.action(
detail=True,
methods=["post"],
url_path="update-participant-role",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsPresentInMeeting,
],
)
def update_participant_role(self, request, pk=None): # pylint: disable=unused-argument
"""Promote or demote a participant currently connected to the meeting.
Requires the requester to be session-authenticated, have privileges
(admin/owner) on the room, and be connected to the meeting.
If the target participant has a user account, the role is persisted
(`ResourceAccess`) then mirrored to their LiveKit attributes.
If the participant is anonymous, the promotion will fail.
"""
room = self.get_object()
serializer = serializers.ParticipantRoleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
participant_identity = serializer.validated_data["participant_identity"]
role = serializer.validated_data["role"]
if str(request.user.sub) == str(participant_identity):
return drf_response.Response(
{"error": "You cannot change your own role."},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
result = RoomRoleService().set_participant_role(
room=room,
participant_identity=participant_identity,
role=role,
actor=request.user,
)
except RoomRoleError as e:
return drf_response.Response({"error": str(e)}, status=e.status_code)
return drf_response.Response(result, status=drf_status.HTTP_200_OK)
@decorators.action( @decorators.action(
detail=True, detail=True,
methods=["post"], methods=["post"],
+9 -2
View File
@@ -9,6 +9,8 @@ from rest_framework import authentication, exceptions
UserModel = get_user_model() UserModel = get_user_model()
LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token"
class LiveKitTokenAuthentication(authentication.BaseAuthentication): class LiveKitTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate using LiveKit token and load the associated Django user.""" """Authenticate using LiveKit token and load the associated Django user."""
@@ -20,9 +22,14 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
return None # No authentication attempted return None # No authentication attempted
parts = auth_header.split() parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer": if not parts or parts[0].lower() != LIVEKIT_AUTH_SCHEME.lower():
# Not our scheme (e.g. "Bearer <user access token>"): defer, another
# backend may recognize it.
return None
if len(parts) != 2:
raise exceptions.AuthenticationFailed( raise exceptions.AuthenticationFailed(
"Authorization header must be: Bearer <token>" f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
) )
token = parts[1] token = parts[1]
@@ -0,0 +1,71 @@
"""User access JWT authentication for the Meet core API.
Allows an embedded frontend (e.g. rendered in an iframe, where third-party
session cookies are blocked) to authenticate requests on the core API with
a JWT, obtained by exchanging a single-use transit code (see
core.services.transit_code and the users exchange-access-token endpoint)
and passed as a Bearer header. The JWT itself never appears in any URL.
Similar to lib-jitsi-meet's token authentication, the token is bound to a
user, not to a resource: once authenticated, the request is treated
exactly like a session-authenticated one, and the existing role-based
permissions apply unchanged.
"""
import logging
from django.conf import settings
from rest_framework import exceptions
from core.external_api.authentication import BaseJWTAuthentication
logger = logging.getLogger(__name__)
USER_ACCESS_TOKEN_TYPE_CLAIM = "user_access" # noqa: S105
class UserAccessJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for user access tokens.
Validates user access tokens issued by the users exchange-access-token
endpoint and authenticates the user they were issued for. A bearer
token that does not verify against the user access token secret is
deferred to the next authentication backend; a token that does verify
but carries wrong claims is rejected.
When the feature is disabled (USER_ACCESS_TOKEN_ENABLED=False), the
backend is entirely inert: `BaseJWTAuthentication.authenticate`
returns None before reading the Authorization header, deferring every
request to the next authentication backend.
"""
def __init__(self):
"""Initialize the backend with user access token settings."""
super().__init__(
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=settings.USER_ACCESS_TOKEN_ALG,
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
token_type=settings.USER_ACCESS_TOKEN_TYPE,
is_enabled=settings.USER_ACCESS_TOKEN_ENABLED,
)
def validate_payload(self, payload):
"""Validate the token type and the issuance-audit claim.
Raises:
AuthenticationFailed: If the token verified against the user
access token secret but does not carry the expected claims.
"""
if payload.get("token_type") != USER_ACCESS_TOKEN_TYPE_CLAIM:
logger.warning("Wrong 'token_type' in user access token payload")
raise exceptions.AuthenticationFailed("Invalid token type.")
# Every token we issue carries the client_id of the application the
# transit code was minted for: its absence means the token does not
# come from the exchange endpoint.
if not payload.get("client_id"):
logger.warning("Missing 'client_id' in user access token payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
@@ -86,6 +86,14 @@ class HasRequiredRoomScope(BaseScopePermission):
} }
class HasRequiredUserScope(BaseScopePermission):
"""Scope-based permissions for the external user endpoints."""
scope_map = {
"generate_transit_code": models.ApplicationScope.USERS_SESSION,
}
class RoomPermissions(permissions.BasePermission): class RoomPermissions(permissions.BasePermission):
"""Permissions applying to the room API endpoint.""" """Permissions applying to the room API endpoint."""
+60
View File
@@ -22,6 +22,7 @@ from rest_framework import (
from core import analytics, api, models from core import analytics, api, models
from core.api.feature_flag import FeatureFlag from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService from core.services.jwt_token import JwtTokenService
from core.services.transit_code import TransitCodeService
from ..services.provisional_user_service import ( from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError, ProvisionalUserCreationDisabledError,
@@ -218,3 +219,62 @@ class RoomViewSet(
"$set": {"email": self.request.user.email}, "$set": {"email": self.request.user.email},
}, },
) )
class UserViewSet(viewsets.GenericViewSet):
"""Application-delegated API for user operations.
Provides JWT-authenticated access to user operations for external
applications acting on behalf of users. All operations are
scope-based. Meant to grow with the other user actions exposed to
third parties.
Supported operations:
- transit-code: Mint a single-use transit code for the delegated user
(requires 'users:session' scope)
"""
authentication_classes = [
authentication.ApplicationJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated & permissions.HasRequiredUserScope
]
@decorators.action(
detail=False,
methods=["post"],
url_path="transit-code",
url_name="transit-code",
)
@FeatureFlag.require("user_access_token")
def generate_transit_code(self, request):
"""Mint a transit code for the delegated user.
Returns a short-lived, single-use opaque code to pass to an embedded
frontend (e.g. via a URL fragment when cookies are unavailable). The
frontend exchanges it once on
POST /api/v1.0/users/exchange-access-token/ for a JWT access token,
equivalent to session-cookie authentication and never exposed in a URL.
"""
auth_method = type(request.successful_authenticator).__name__
client_id = (request.auth or {}).get("client_id", "unknown")
code = TransitCodeService().create_code(request.user, client_id=client_id)
# Log for auditing
logger.info(
"Transit code issued: user_id=%s, client_id=%s, auth_method=%s",
request.user.id,
client_id,
auth_method,
)
return drf_response.Response(
{
"transit_code": code,
"expires_in": settings.TRANSIT_CODE_TTL,
},
status=drf_status.HTTP_200_OK,
)
@@ -0,0 +1,19 @@
# Generated by Django 5.2.14 on 2026-07-31 18:27
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0021_recording_external_process_id_alter_recording_status'),
]
operations = [
migrations.AlterField(
model_name='application',
name='scopes',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms'), ('users:session', 'Create user session tokens')], max_length=50), blank=True, default=list, size=None),
),
]
+1
View File
@@ -769,6 +769,7 @@ class ApplicationScope(models.TextChoices):
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details") ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
ROOMS_UPDATE = "rooms:update", _("Update rooms") ROOMS_UPDATE = "rooms:update", _("Update rooms")
ROOMS_DELETE = "rooms:delete", _("Delete rooms") ROOMS_DELETE = "rooms:delete", _("Delete rooms")
USERS_SESSION = "users:session", _("Create user session tokens")
class Application(BaseModel): class Application(BaseModel):
@@ -9,6 +9,11 @@ from livekit import api
from core import models, utils from core import models, utils
from core.models import Recording from core.models import Recording
from core.recording.event.notification import notification_service from core.recording.event.notification import notification_service
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -39,10 +44,15 @@ class RecordingEventsService:
recording_status = status_mapping.get(egress_status) recording_status = status_mapping.get(egress_status)
if recording_status: if recording_status:
try: try:
utils.update_room_metadata( RoomManagement().update_metadata(
room_name, {"recording_status": recording_status} room_name, {"recording_status": recording_status}
) )
except utils.MetadataUpdateException as e: except RoomNotFoundException:
logger.info(
"LiveKit room %s no longer exists, skipping metadata update",
room_name,
)
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e) logger.exception("Failed to update room's metadata: %s", e)
@staticmethod @staticmethod
+12 -3
View File
@@ -2,8 +2,12 @@
import logging import logging
from core import utils
from core.models import Recording, RecordingStatusChoices from core.models import Recording, RecordingStatusChoices
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from .exceptions import ( from .exceptions import (
RecordingStartError, RecordingStartError,
@@ -64,10 +68,15 @@ class WorkerServiceMediator:
mode = recording.options.get("original_mode", None) or recording.mode mode = recording.options.get("original_mode", None) or recording.mode
try: try:
utils.update_room_metadata( RoomManagement().update_metadata(
room_name, {"recording_mode": mode, "recording_status": "starting"} room_name, {"recording_mode": mode, "recording_status": "starting"}
) )
except utils.MetadataUpdateException as e: except RoomNotFoundException:
logger.info(
"LiveKit room %s no longer exists, skipping metadata update",
room_name,
)
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e) logger.exception("Failed to update room's metadata: %s", e)
logger.info( logger.info(
+14 -4
View File
@@ -11,7 +11,7 @@ from django.conf import settings
from livekit import api from livekit import api
from core import models, utils from core import models
from core.recording.services.metadata_collector import ( from core.recording.services.metadata_collector import (
MetadataCollectorException, MetadataCollectorException,
MetadataCollectorService, MetadataCollectorService,
@@ -23,6 +23,11 @@ from core.recording.services.recording_events import (
) )
from .lobby import LobbyService from .lobby import LobbyService
from .room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from .telephony import TelephonyException, TelephonyService from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -177,10 +182,15 @@ class LiveKitEventsService:
try: try:
room_name = str(recording.room.id) room_name = str(recording.room.id)
utils.update_room_metadata( RoomManagement().update_metadata(
room_name, {}, ["recording_mode", "recording_status"] room_name, remove_keys=["recording_mode", "recording_status"]
) )
except utils.MetadataUpdateException as e: except RoomNotFoundException:
logger.info(
"LiveKit room %s no longer exists, skipping metadata update",
room_name,
)
except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e) logger.exception("Failed to update room's metadata: %s", e)
if recording.options.get("metadata_collector_dispatch_id", None) is not None: if recording.options.get("metadata_collector_dispatch_id", None) is not None:
+52 -60
View File
@@ -87,38 +87,30 @@ class LobbyService:
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}" return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
@staticmethod @staticmethod
def _get_or_create_participant_id(request) -> str: def can_bypass_lobby(room, user, role) -> bool:
"""Extract unique participant identifier from the request."""
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
@staticmethod
def prepare_response(response, participant_id):
"""Set participant cookie if needed."""
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
response.set_cookie(
key=settings.LOBBY_COOKIE_NAME,
value=participant_id,
httponly=True,
secure=True,
samesite="Lax",
)
@staticmethod
def can_bypass_lobby(room, user) -> bool:
"""Determines if a user can bypass the waiting lobby and join a room directly. """Determines if a user can bypass the waiting lobby and join a room directly.
A user can bypass the lobby if: A user can bypass the lobby if:
1. The room is public (open to everyone) 1. The room is public (open to everyone)
2. The room has TRUSTED access level and the user is authenticated 2. The room has TRUSTED access level and the user is authenticated
2. The room has RESTRICTED access level and the user has any role
Note: Room access levels can change while participants are waiting in the lobby. Note: Room access levels can change while participants are waiting in the lobby.
This function only checks the current state and should be called each time This function only checks the current state and should be called each time
a participant requests entry to ensure consistent access control, even for a participant requests entry to ensure consistent access control, even for
participants who have already begun waiting. participants who have already begun waiting.
""" """
return room.is_public or ( return (
room.access_level == models.RoomAccessLevel.TRUSTED room.is_public
and user.is_authenticated or (
room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated
)
or (
room.access_level == models.RoomAccessLevel.RESTRICTED
and user.is_authenticated
and role is not None
)
) )
def request_entry( def request_entry(
@@ -126,6 +118,7 @@ class LobbyService:
room: models.Room, room: models.Room,
request, request,
username: str, username: str,
participant_id: Optional[uuid.UUID] = None,
) -> Tuple[LobbyParticipant, Optional[Dict]]: ) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant. """Request entry to a room for a participant.
@@ -140,21 +133,20 @@ class LobbyService:
5. If denied, do nothing. 5. If denied, do nothing.
""" """
participant_id = self._get_or_create_participant_id(request) participant = None
participant = self._get_participant(room.id, participant_id) if participant_id:
participant = self._get_participant(room.id, participant_id)
is_new_participant = participant is None
if is_new_participant:
participant = self._create_participant(room.id, username)
room_id = str(room.id) room_id = str(room.id)
user_role = room.get_role(request.user)
if self.can_bypass_lobby(room=room, user=request.user): if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
if participant is None: participant.status = LobbyParticipantStatus.ACCEPTED
participant = LobbyParticipant( self._save_participant(room.id, participant)
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
livekit_config = utils.generate_livekit_config( livekit_config = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
@@ -162,18 +154,18 @@ class LobbyService:
username=username, username=username,
color=participant.color, color=participant.color,
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False, participant_id=participant.id,
participant_id=participant_id, role=user_role,
) )
return participant, livekit_config return participant, livekit_config
livekit_config = None livekit_config = None
if participant is None: if is_new_participant:
participant = self.enter(room.id, participant_id, username) self._notify_entry_request(room_id)
elif participant.status == LobbyParticipantStatus.WAITING: elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id) self.refresh_waiting_status(room.id, participant.id)
elif participant.status == LobbyParticipantStatus.ACCEPTED: elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room # wrongly named, contains access token to join a room
@@ -183,8 +175,8 @@ class LobbyService:
username=username, username=username,
color=participant.color, color=participant.color,
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False, participant_id=participant.id,
participant_id=participant_id, role=user_role,
) )
return participant, livekit_config return participant, livekit_config
@@ -200,27 +192,36 @@ class LobbyService:
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
) )
def enter( def _create_participant(self, room_id: UUID, username: str) -> LobbyParticipant:
self, room_id: UUID, participant_id: str, username: str """Create and persist a new waiting participant.
) -> LobbyParticipant:
"""Add participant to waiting lobby.
Create a new participant entry in waiting status and notify room Participant identifiers are minted here, server-side, exclusively.
participants of the new entry request.
""" """
participant_id = str(uuid.uuid4())
color = utils.generate_color(participant_id)
participant = LobbyParticipant( participant = LobbyParticipant(
status=LobbyParticipantStatus.WAITING, status=LobbyParticipantStatus.WAITING,
username=username, username=username,
id=participant_id, id=participant_id,
color=color, color=utils.generate_color(participant_id),
)
self._save_participant(room_id, participant)
return participant
def _save_participant(self, room_id: UUID, participant: LobbyParticipant):
"""Persist a participant in the room's lobby."""
cache.set(
self._get_cache_key(room_id, participant.id),
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
) )
@staticmethod
def _notify_entry_request(room_id: str):
"""Notify room participants of a new entry request."""
try: try:
utils.notify_participants( utils.notify_participants(
room_name=str(room_id), room_name=room_id,
notification_data={ notification_data={
"type": settings.LOBBY_NOTIFICATION_TYPE, "type": settings.LOBBY_NOTIFICATION_TYPE,
}, },
@@ -229,15 +230,6 @@ class LobbyService:
# If room not created yet, there is no participants to notify # If room not created yet, there is no participants to notify
logger.exception("Failed to notify room participants") logger.exception("Failed to notify room participants")
cache_key = self._get_cache_key(room_id, participant_id)
cache.set(
cache_key,
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
return participant
def _get_participant( def _get_participant(
self, room_id: UUID, participant_id: str self, room_id: UUID, participant_id: str
) -> Optional[LobbyParticipant]: ) -> Optional[LobbyParticipant]:
+29 -3
View File
@@ -8,6 +8,7 @@ from typing import Dict, Optional
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from livekit.api import ( from livekit.api import (
ListRoomsRequest,
TwirpError, TwirpError,
UpdateRoomMetadataRequest, UpdateRoomMetadataRequest,
) )
@@ -29,20 +30,45 @@ class RoomManagement:
"""Service for managing LiveKit rooms.""" """Service for managing LiveKit rooms."""
@async_to_sync @async_to_sync
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None): async def update_metadata(
"""Update a LiveKit room's metadata. self,
room_name: str,
metadata: Optional[Dict] = None,
remove_keys: Optional[list[str]] = None,
):
"""Merge values into a LiveKit room's metadata.
The `room_name` corresponds to the LiveKit room identifier The `room_name` corresponds to the LiveKit room identifier
(i.e. the Room model's UUID as a string). (i.e. the Room model's UUID as a string).
Raises:
RoomNotFoundException: the room does not exist in LiveKit.
RoomManagementException: the metadata update otherwise fails.
""" """
lkapi = utils.create_livekit_client() lkapi = utils.create_livekit_client()
try: try:
response = await lkapi.room.list_rooms(ListRoomsRequest(names=[room_name]))
if not response.rooms:
logger.warning(
"Room %s not found in LiveKit, skipping metadata update",
room_name,
)
raise RoomNotFoundException("Room does not exist")
existing_metadata = json.loads(response.rooms[0].metadata or "{}")
for key in remove_keys or []:
existing_metadata.pop(key, None)
updated_metadata = {**existing_metadata, **(metadata or {})}
await lkapi.room.update_room_metadata( await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest( UpdateRoomMetadataRequest(
room=room_name, room=room_name,
metadata=json.dumps(metadata) if metadata is not None else "", metadata=json.dumps(updated_metadata),
) )
) )
+178
View File
@@ -0,0 +1,178 @@
"""Room role management service.
Single entry point for changing a user's role on a room, used by:
- the in-meeting endpoint (promote/demote a connected participant)
- (more to come soon)
`ResourceAccess` is the source of truth. The LiveKit `room_role`
participant attribute is only a projection of it, synced best-effort.
"""
from logging import getLogger
from uuid import UUID
from core import models
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
logger = getLogger(__name__)
class RoomRoleError(Exception):
"""Base exception for room role management errors."""
status_code = 400
class SelfActionError(RoomRoleError):
"""Raised when a user tries to change their own role."""
status_code = 403
class OwnerRoleError(RoomRoleError):
"""Raised when trying to demote an owner or grant ownership."""
status_code = 403
class ParticipantNotInMeetingError(RoomRoleError):
"""Raised when the target participant is not connected to the meeting."""
status_code = 404
class UserNotFoundError(RoomRoleError):
"""Raised when the target participant has no user account in database."""
status_code = 404
ASSIGNABLE_ROLES = (models.RoleChoices.MEMBER, models.RoleChoices.ADMIN)
class RoomRoleService:
"""Manage promotion and demotion of room co-hosts."""
def set_role(
self, room: models.Room, user: models.User, role: str, actor: models.User
):
"""Persist `role` for `user` on `room`, idempotently and atomically.
Returns the up-to-date `ResourceAccess`. Never grants or removes
ownership: granting OWNER is refused, and an existing OWNER access
is never modified.
"""
if role not in ASSIGNABLE_ROLES:
raise OwnerRoleError("Ownership cannot be granted through this action.")
if actor is not None and user == actor:
raise SelfActionError("You cannot change your own role.")
access, created = models.ResourceAccess.objects.get_or_create(
resource=room,
user=user,
defaults={"role": role},
)
if created:
return access
if access.role == models.RoleChoices.OWNER:
raise OwnerRoleError("Room owners cannot be demoted.")
if access.role != role:
access.role = role
access.save(update_fields=["role", "updated_at"])
return access
def set_participant_role(
self,
room: models.Room,
participant_identity: UUID,
role: str,
actor: models.User,
):
"""Change the role of a participant currently connected to the meeting.
- The participant must be connected (checked against LiveKit).
- The participant must map to a user account.
- The role is persisted in DB then mirrored to LiveKit.
Returns a dict: {"role", "livekit_synced"}.
"""
room_name = str(room.pk)
participants_management = ParticipantsManagement()
try:
is_in_meeting = participants_management.check_if_in_meeting(
room_name=room_name, identity=str(participant_identity)
)
except ParticipantNotFoundException as e:
raise ParticipantNotInMeetingError(
"Participant is not connected to this meeting."
) from e
if not is_in_meeting:
raise ParticipantNotInMeetingError(
"Participant is not connected to this meeting."
)
user = models.User.objects.filter(sub=participant_identity).first()
if user is None:
raise UserNotFoundError(
"This participant has no user account and cannot be assigned a role."
)
# Source of truth first: even if the LiveKit sync below fails,
# the role is real and any fresh token will carry it.
self.set_role(room=room, user=user, role=role, actor=actor)
livekit_synced = self._sync_livekit_role(
room_name=room_name,
participant_identity=str(participant_identity),
role=str(role),
)
return {
"role": role,
"livekit_synced": livekit_synced,
}
@staticmethod
def _sync_livekit_role(room_name: str, participant_identity: str, role: str):
"""Mirror the role to the participant's LiveKit attributes.
Best-effort: returns False on failure instead of raising, so callers
can report a partial success. Re-running the action re-syncs.
"""
try:
ParticipantsManagement().update(
room_name=room_name,
identity=participant_identity,
attributes={"room_role": role},
)
except ParticipantNotFoundException:
# The participant left between the presence check and the update:
# harmless, the DB state (if any) remains authoritative.
logger.info(
"Participant %s left room %s before role sync",
participant_identity,
room_name,
)
return False
except ParticipantsManagementException:
logger.exception(
"Could not sync role to LiveKit for participant %s in room %s",
participant_identity,
room_name,
)
return False
return True
+74
View File
@@ -0,0 +1,74 @@
"""Service handling the lifecycle of transit codes.
A transit code is an opaque, cryptographically random, single-use code
handed to an embedded frontend (through a URL fragment) so it can obtain a
user access token on the core API without a session cookie. The code
carries no information by itself: everything it references (user, client)
is stored server-side in the cache, and consumed atomically on exchange.
"""
import hashlib
import secrets
from django.conf import settings
from django.core.cache import cache
class TransitCodeService:
"""Create and consume single-use transit codes."""
@staticmethod
def _cache_key(code):
"""Build the cache key for a code.
The code is hashed so that a dump of the cache never reveals
directly usable codes.
"""
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{digest}"
def create_code(self, user, client_id="unknown"):
"""Generate a transit code for a user, and store it.
The code expires after TRANSIT_CODE_TTL seconds.
Returns:
str: The opaque code to hand to the client.
"""
# Default 48 random bytes -> 64 url-safe characters, 384 bits of
# entropy: unguessable and safe to transit through a URL fragment.
code = secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
cache.set(
self._cache_key(code),
{
"user_id": str(user.id),
"client_id": client_id,
},
timeout=settings.TRANSIT_CODE_TTL,
)
return code
def consume_code(self, code):
"""Consume a transit code, enforcing single use.
The code is deleted from the cache upon consumption. `cache.delete`
returns whether a key was actually deleted, so if two requests race
on the same code, only one of them wins.
Returns:
dict | None: The data stored at creation time ('user_id',
'client_id'), or None if the code is unknown, expired or
already consumed.
"""
if not code:
return None
key = self._cache_key(code)
data = cache.get(key)
if data is None or not cache.delete(key):
return None
return data
@@ -34,10 +34,8 @@ def mediator(mock_worker_service):
return WorkerServiceMediator(mock_worker_service) return WorkerServiceMediator(mock_worker_service)
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_start_recording_success( def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
mock_update_room_metadata, mediator, mock_worker_service
):
"""Test successful recording start""" """Test successful recording start"""
# Setup # Setup
worker_id = "test-worker-123" worker_id = "test-worker-123"
@@ -60,7 +58,7 @@ def test_start_recording_success(
assert mock_recording.worker_id == worker_id assert mock_recording.worker_id == worker_id
assert mock_recording.status == RecordingStatusChoices.ACTIVE assert mock_recording.status == RecordingStatusChoices.ACTIVE
mock_update_room_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(mock_recording.room.id), str(mock_recording.room.id),
{"recording_mode": mock_recording.mode, "recording_status": "starting"}, {"recording_mode": mock_recording.mode, "recording_status": "starting"},
) )
@@ -69,9 +67,9 @@ def test_start_recording_success(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError] "error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
) )
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_mediator_start_recording_worker_errors( def test_mediator_start_recording_worker_errors(
mock_update_room_metadata, mediator, mock_worker_service, error_class mock_update_metadata, mediator, mock_worker_service, error_class
): ):
"""Test handling of various worker errors during start""" """Test handling of various worker errors during start"""
# Setup # Setup
@@ -89,7 +87,7 @@ def test_mediator_start_recording_worker_errors(
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
assert mock_recording.worker_id is None assert mock_recording.worker_id is None
mock_update_room_metadata.assert_not_called() mock_update_metadata.assert_not_called()
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -103,9 +101,9 @@ def test_mediator_start_recording_worker_errors(
RecordingStatusChoices.ABORTED, RecordingStatusChoices.ABORTED,
], ],
) )
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_mediator_start_recording_from_forbidden_status( def test_mediator_start_recording_from_forbidden_status(
mock_update_room_metadata, mediator, mock_worker_service, status mock_update_metadata, mediator, mock_worker_service, status
): ):
"""Test handling of various worker errors during start""" """Test handling of various worker errors during start"""
# Setup # Setup
@@ -119,7 +117,7 @@ def test_mediator_start_recording_from_forbidden_status(
mock_recording.refresh_from_db() mock_recording.refresh_from_db()
assert mock_recording.status == status assert mock_recording.status == status
mock_update_room_metadata.assert_not_called() mock_update_metadata.assert_not_called()
def test_mediator_stop_recording_success(mediator, mock_worker_service): def test_mediator_stop_recording_success(mediator, mock_worker_service):
@@ -2,9 +2,14 @@
Test rooms API endpoints in the Meet core app: create. Test rooms API endpoints in the Meet core app: create.
""" """
from datetime import datetime, timedelta, timezone
from django.conf import settings as django_settings
# pylint: disable=redefined-outer-name,unused-argument # pylint: disable=redefined-outer-name,unused-argument
from django.core.cache import cache from django.core.cache import cache
import jwt
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -109,3 +114,38 @@ 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 generate_user_access_token(user):
"""Generate a valid user access JWT signed with the token secret."""
now = datetime.now(timezone.utc)
payload = {
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
"scope": "user:access",
}
return jwt.encode(
payload,
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
def test_api_rooms_create_authenticated_with_user_access_token():
"""A user access token should create a room exactly like a session would."""
user = UserFactory()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.post("/api/v1.0/rooms/", {"name": "my room"})
assert response.status_code == 201
room = Room.objects.get()
assert room.accesses.filter(role="owner", user=user).exists()
@@ -2,8 +2,12 @@
Test rooms API endpoints in the Meet core app: list. Test rooms API endpoints in the Meet core app: list.
""" """
from datetime import datetime, timedelta, timezone
from unittest import mock from unittest import mock
from django.conf import settings as django_settings
import jwt
import pytest import pytest
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -156,3 +160,40 @@ def test_api_rooms_list_pagination_page_size():
assert len(content["results"]) == 3 assert len(content["results"]) == 3
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3" assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
assert content["previous"] is None assert content["previous"] is None
def generate_user_access_token(user):
"""Generate a valid user access JWT signed with the token secret."""
now = datetime.now(timezone.utc)
payload = {
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
"scope": "user:access",
}
return jwt.encode(
payload,
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
def test_api_rooms_list_authenticated_with_user_access_token():
"""A user access token should list rooms exactly like a session would."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
RoomFactory() # another user's room, not listed
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 1
assert response.data["results"][0]["id"] == str(room.id)
@@ -14,9 +14,6 @@ from rest_framework.test import APIClient
from ... import utils from ... import utils
from ...factories import RoomFactory, UserFactory from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel from ...models import RoomAccessLevel
from ...services.lobby import (
LobbyService,
)
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -29,7 +26,6 @@ def test_request_entry_anonymous(settings):
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient() client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request # Lobby cache should be empty before the request
@@ -47,11 +43,10 @@ def test_request_entry_anonymous(settings):
assert response.status_code == 200 assert response.status_code == 200
# Verify the lobby cookie was properly set # The participant identifier is returned in the response body; no
cookie = response.cookies.get("mocked-cookie") # cookie is involved anymore
assert cookie is not None assert not response.cookies
participant_id = response.json()["id"]
participant_id = cookie.value
# Verify response content matches expected structure and values # Verify response content matches expected structure and values
assert response.json() == { assert response.json() == {
@@ -78,7 +73,6 @@ def test_request_entry_authenticated_user(settings):
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request # Lobby cache should be empty before the request
@@ -96,11 +90,10 @@ def test_request_entry_authenticated_user(settings):
assert response.status_code == 200 assert response.status_code == 200
# Verify the lobby cookie was properly set # The participant identifier is returned in the response body; no
cookie = response.cookies.get("mocked-cookie") # cookie is involved anymore
assert cookie is not None assert not response.cookies
participant_id = response.json()["id"]
participant_id = cookie.value
# Verify response content matches expected structure and values # Verify response content matches expected structure and values
assert response.json() == { assert response.json() == {
@@ -127,7 +120,6 @@ def test_request_entry_with_existing_participants(settings):
client = APIClient() client = APIClient()
# Configure test settings for cookies and cache # Configure test settings for cookies and cache
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add two participants already waiting in the lobby # Add two participants already waiting in the lobby
@@ -168,11 +160,10 @@ def test_request_entry_with_existing_participants(settings):
# Verify successful response # Verify successful response
assert response.status_code == 200 assert response.status_code == 200
# Verify the lobby cookie was properly set for the new participant # The participant identifier is returned in the response body; no
cookie = response.cookies.get("mocked-cookie") # cookie is involved anymore
assert cookie is not None assert not response.cookies
participant_id = response.json()["id"]
participant_id = cookie.value
# Verify response content matches expected structure and values # Verify response content matches expected structure and values
assert response.json() == { assert response.json() == {
@@ -197,7 +188,6 @@ def test_request_entry_public_room(settings):
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient() client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request # Lobby cache should be empty before the request
@@ -206,9 +196,7 @@ def test_request_entry_public_room(settings):
with ( with (
mock.patch.object(utils, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object( mock.patch("core.services.lobby.uuid.uuid4", return_value="123"),
LobbyService, "_get_or_create_participant_id", return_value="123"
),
mock.patch.object( mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"} utils, "generate_livekit_config", return_value={"token": "test-token"}
), ),
@@ -221,11 +209,6 @@ def test_request_entry_public_room(settings):
assert response.status_code == 200 assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "123"
# Verify response content matches expected structure and values # Verify response content matches expected structure and values
assert response.json() == { assert response.json() == {
"id": "123", "id": "123",
@@ -235,9 +218,10 @@ def test_request_entry_public_room(settings):
"livekit": {"token": "test-token"}, "livekit": {"token": "test-token"},
} }
# Verify lobby cache is still empty after the request # The accepted participant is persisted, out of the waiting list
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys assert len(lobby_keys) == 1
assert cache.get(lobby_keys[0])["status"] == "accepted"
def test_request_entry_authenticated_user_public_room(settings): def test_request_entry_authenticated_user_public_room(settings):
@@ -247,7 +231,6 @@ def test_request_entry_authenticated_user_public_room(settings):
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request # Lobby cache should be empty before the request
@@ -256,9 +239,8 @@ def test_request_entry_authenticated_user_public_room(settings):
with ( with (
mock.patch.object(utils, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object( mock.patch(
LobbyService, "core.services.lobby.uuid.uuid4",
"_get_or_create_participant_id",
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def", return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
), ),
mock.patch.object( mock.patch.object(
@@ -273,11 +255,6 @@ def test_request_entry_authenticated_user_public_room(settings):
assert response.status_code == 200 assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
# Verify response content matches expected structure and values # Verify response content matches expected structure and values
assert response.json() == { assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
@@ -287,9 +264,10 @@ def test_request_entry_authenticated_user_public_room(settings):
"livekit": {"token": "test-token"}, "livekit": {"token": "test-token"},
} }
# Verify lobby cache is still empty after the request # The accepted participant is persisted, out of the waiting list
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*") lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys assert len(lobby_keys) == 1
assert cache.get(lobby_keys[0])["status"] == "accepted"
def test_request_entry_waiting_participant_public_room(settings): def test_request_entry_waiting_participant_public_room(settings):
@@ -297,7 +275,6 @@ def test_request_entry_waiting_participant_public_room(settings):
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient() client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add a waiting participant to the room's lobby cache # Add a waiting participant to the room's lobby cache
@@ -311,9 +288,7 @@ def test_request_entry_waiting_participant_public_room(settings):
}, },
) )
# Simulate a browser with existing participant cookie # Simulate a returning participant echoing its identifier
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
with ( with (
mock.patch.object(utils, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object( mock.patch.object(
@@ -322,16 +297,14 @@ def test_request_entry_waiting_participant_public_room(settings):
): ):
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "user1"}, {
"username": "user1",
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
},
) )
assert response.status_code == 200 assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
# Verify response content matches expected structure and values # Verify response content matches expected structure and values
assert response.json() == { assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
@@ -637,15 +610,14 @@ def test_list_waiting_participants_empty(settings):
@mock.patch.object( @mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"} utils, "generate_livekit_config", return_value={"token": "test-token"}
) )
def test_request_entry_throttling_anonymous_without_cookie( def test_request_entry_throttling_anonymous_unidentified(
mock_notify_participants, mock_generate_livekit_config, settings mock_notify_participants, mock_generate_livekit_config, settings
): ):
"""Anonymous users without a cookie should not be throttled.""" """Requests without a participant identifier should not be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient() client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute" settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
response = client.post( response = client.post(
@@ -654,9 +626,6 @@ def test_request_entry_throttling_anonymous_without_cookie(
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.cookies.get("mocked-cookie") is not None
client.cookies.clear() # Simulate a new cookieless request
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", f"/api/v1.0/rooms/{room.id}/request-entry/",
@@ -670,34 +639,32 @@ def test_request_entry_throttling_anonymous_without_cookie(
@mock.patch.object( @mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"} utils, "generate_livekit_config", return_value={"token": "test-token"}
) )
def test_request_entry_throttling_anonymous_with_cookie( def test_request_entry_throttling_anonymous_identified(
mock_notify_participants, mock_generate_livekit_config, settings mock_notify_participants, mock_generate_livekit_config, settings
): ):
"""Anonymous users with a cookie should be throttled after exceeding the rate limit.""" """Identified requests should be throttled after exceeding the rate limit."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient() client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute" settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
participant_id = str(uuid.uuid4()) participant_id = str(uuid.uuid4())
client.cookies.load({"mocked-cookie": participant_id})
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"}, {"username": "test_user", "participant_id": participant_id},
) )
assert response.status_code == 200 assert response.status_code == 200
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"}, {"username": "test_user", "participant_id": participant_id},
) )
assert response.status_code == 200 assert response.status_code == 200
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/", f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"}, {"username": "test_user", "participant_id": participant_id},
) )
assert response.status_code == 429 assert response.status_code == 429
@@ -716,7 +683,6 @@ def test_request_entry_throttling_authenticated_user(
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute" settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
response = client.post( response = client.post(
@@ -737,3 +703,124 @@ def test_request_entry_throttling_authenticated_user(
) )
assert response.status_code == 429 assert response.status_code == 429
def test_request_entry_with_participant_id(settings):
"""Echoing the previously issued identifier preserves the lobby identity across requests."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
participant_id = response.json()["id"]
# Echoing the identifier must be recognized as the same
# participant: no duplicate in the lobby
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 200
assert response.json()["id"] == participant_id
assert response.json()["status"] == "waiting"
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 1
def test_request_entry_unknown_participant_id_not_seeded(settings):
"""An identifier unknown to the room's lobby must not be honored."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
forged_id = str(uuid.uuid4())
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user", "participant_id": forged_id},
)
assert response.status_code == 200
assert response.json()["id"] != forged_id
# Nothing was stored under the forged identifier
assert cache.get(f"mocked-cache-prefix_{room.id}_{forged_id}") is None
def test_request_entry_participant_id_bound_to_room(settings):
"""An identifier minted for one room must not be honored in another."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
other_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
participant_id = response.json()["id"]
response = client.post(
f"/api/v1.0/rooms/{other_room.id}/request-entry/",
{"username": "test_user", "participant_id": participant_id},
)
assert response.status_code == 200
assert response.json()["id"] != participant_id
def test_request_entry_legacy_cookie_ignored():
"""The retired cookie channel must not be honored anymore."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
legacy_participant_id = str(uuid.uuid4())
client.cookies["lobbyParticipantId"] = legacy_participant_id
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
returned_id = response.json()["id"]
assert returned_id != legacy_participant_id
uuid.UUID(returned_id)
def test_request_entry_malformed_participant_id(settings):
"""A non-UUID identifier is rejected by the serializer with a 400."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user", "participant_id": "../../../evil-key"},
)
assert response.status_code == 400
assert "participant_id" in response.json()
@@ -20,7 +20,11 @@ from rest_framework.test import APIClient
from core import utils from core import utils
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.lobby import LobbyService from core.services.lobby import (
LobbyParticipant,
LobbyParticipantStatus,
LobbyService,
)
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -80,14 +84,14 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
room = RoomFactory() room = RoomFactory()
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -106,14 +110,14 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
other_room = RoomFactory() other_room = RoomFactory()
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(other_room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post( response = client.post(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -146,14 +150,14 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
room = RoomFactory(configuration={"everyone_can_mute": False}) room = RoomFactory(configuration={"everyone_can_mute": False})
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -293,14 +297,14 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
) )
# Token identity matches the admin user so LiveKitTokenAuthentication # Token identity matches the admin user so LiveKitTokenAuthentication
# resolves request.user back to the admin. # resolves request.user back to the admin.
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -323,14 +327,14 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
# Token is scoped to a DIFFERENT room, and admin status must only be # Token is scoped to a DIFFERENT room, and admin status must only be
# honored when established via session, never via a LiveKit # honored when established via session, never via a LiveKit
# token, which can be replayed off-host. # token, which can be replayed off-host.
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True) token = utils.generate_token(str(other_room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post( response = client.post(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -354,14 +358,14 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
role=random.choice(["administrator", "owner"]), role=random.choice(["administrator", "owner"]),
) )
# The token is the only credential. # The token is the only credential.
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True) token = utils.generate_token(str(room.id), admin_user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -374,14 +378,14 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
room = RoomFactory() room = RoomFactory()
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -405,14 +409,14 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
) )
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -433,14 +437,14 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
) )
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -462,14 +466,14 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
) )
user = AnonymousUser() user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -719,13 +723,13 @@ def test_update_participant_invalid_payload():
) )
client.force_authenticate(user=user) client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"} payload = {"participant_identity": ["test"]}
url = reverse("rooms-update-participant", kwargs={"pk": room.id}) url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json") response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Must be a valid UUID." in str(response.data) assert "Not a valid string." in str(response.data)
def test_update_participant_no_update_fields(): def test_update_participant_no_update_fields():
@@ -849,7 +853,15 @@ def test_remove_participant_success_lobby_cache(mock_livekit_client):
participant_identity = str(uuid4()) participant_identity = str(uuid4())
# Create participant in lobby cache first # Create participant in lobby cache first
LobbyService().enter(room.id, participant_identity, "John doe") LobbyService()._save_participant(
room.id,
LobbyParticipant(
id=participant_identity,
username="John doe",
status=LobbyParticipantStatus.WAITING,
color="#123456",
),
)
# Accept participant # Accept participant
LobbyService().handle_participant_entry(room.id, participant_identity, True) LobbyService().handle_participant_entry(room.id, participant_identity, True)
@@ -918,7 +930,7 @@ def test_remove_participant_invalid_payload():
) )
client.force_authenticate(user=user) client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"} payload = {"participant_identity": ["invalid-uuid"]}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id}) url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json") response = client.post(url, payload, format="json")
@@ -1020,3 +1032,6 @@ def test_remove_participant_not_found(mock_livekit_client):
assert response.data == {"error": "Participant not found"} assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once() mock_livekit_client.aclose.assert_called_once()
# todo - try to pass another scheme to make sure it defers to the next auth
@@ -69,7 +69,10 @@ def test_toggle_hand_raise_success(mock_livekit_client, room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-token {token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -84,7 +87,10 @@ def test_toggle_hand_lower_success(mock_livekit_client, room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"raised": False},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -101,7 +107,10 @@ def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -117,7 +126,10 @@ def test_toggle_hand_identity_derived_from_token(
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
client.post( client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
call_kwargs = mock_livekit_client.room.update_participant.call_args call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -128,7 +140,9 @@ def test_toggle_hand_missing_raised_field(room, token):
"""Test toggle hand with missing raised field returns 400.""" """Test toggle hand with missing raised field returns 400."""
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}") response = client.post(
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "raised" in response.data assert "raised" in response.data
@@ -142,7 +156,7 @@ def test_toggle_hand_invalid_raised_field(room, token):
url, url,
{"raised": "not-a-boolean"}, {"raised": "not-a-boolean"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -166,7 +180,10 @@ def test_toggle_hand_forbidden_token_for_wrong_room(user):
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
response = client.post( response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}" url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -181,7 +198,10 @@ def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -200,7 +220,7 @@ def test_toggle_hand_raise_success_anonymous(
url, url,
{"raised": True}, {"raised": True},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -220,7 +240,7 @@ def test_toggle_hand_lower_success_anonymous(
url, url,
{"raised": False}, {"raised": False},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -240,7 +260,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous(
url, url,
{"raised": True}, {"raised": True},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
) )
call_kwargs = mock_livekit_client.room.update_participant.call_args call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -257,7 +277,10 @@ def test_rename_participant_success(mock_livekit_client, room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -272,7 +295,10 @@ def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post( client.post(
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"name": "Jane Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
call_kwargs = mock_livekit_client.room.update_participant.call_args call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -286,7 +312,10 @@ def test_rename_participant_uses_identity_from_token(
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post( client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
call_kwargs = mock_livekit_client.room.update_participant.call_args call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -298,7 +327,7 @@ def test_rename_participant_empty_name(room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
) )
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -309,7 +338,9 @@ def test_rename_participant_missing_name(room, token):
"""Test rename with missing name field returns 400.""" """Test rename with missing name field returns 400."""
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}") response = client.post(
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "name" in response.data assert "name" in response.data
@@ -320,7 +351,10 @@ def test_rename_participant_name_too_long(room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"name": "a" * 256},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -348,7 +382,7 @@ def test_rename_participant_forbidden_token_for_wrong_room(user):
url, url,
{"name": "John Doe"}, {"name": "John Doe"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {wrong_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -363,7 +397,10 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -382,7 +419,7 @@ def test_rename_participant_success_anonymous(
url, url,
{"name": "Guest User"}, {"name": "Guest User"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
@@ -402,7 +439,7 @@ def test_rename_participant_uses_identity_from_token_anonymous(
url, url,
{"name": "Guest User"}, {"name": "Guest User"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
) )
call_kwargs = mock_livekit_client.room.update_participant.call_args call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -419,7 +456,7 @@ def test_rename_participant_sets_correct_name_anonymous(
url, url,
{"name": "Guest User"}, {"name": "Guest User"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
) )
call_kwargs = mock_livekit_client.room.update_participant.call_args call_kwargs = mock_livekit_client.room.update_participant.call_args
@@ -436,7 +473,7 @@ def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_t
url, url,
{"name": "Guest User"}, {"name": "Guest User"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -462,7 +499,7 @@ def test_toggle_hand_expired_token(room, expired_token):
url, url,
{"raised": True}, {"raised": True},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {expired_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -476,7 +513,7 @@ def test_rename_participant_expired_token(room, expired_token):
url, url,
{"name": "John Doe"}, {"name": "John Doe"},
format="json", format="json",
HTTP_AUTHORIZATION=f"Bearer {expired_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -490,7 +527,7 @@ def test_toggle_hand_malformed_token(room):
url, url,
{"raised": True}, {"raised": True},
format="json", format="json",
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt", HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -504,7 +541,10 @@ def test_toggle_hand_room_not_found(user):
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id}) url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
response = client.post( response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -519,7 +559,10 @@ def test_toggle_hand_participant_not_found(mock_livekit_client, room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -536,7 +579,7 @@ def test_rename_participant_malformed_token(room):
url, url,
{"name": "John Doe"}, {"name": "John Doe"},
format="json", format="json",
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt", HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
) )
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -550,7 +593,10 @@ def test_rename_participant_room_not_found(user):
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id}) url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
response = client.post( response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -565,10 +611,16 @@ def test_rename_participant_not_found(mock_livekit_client, room, token):
client = APIClient() client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id}) url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post( response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}" url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
) )
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"} assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once() mock_livekit_client.aclose.assert_called_once()
# todo - try to pass another scheme to make sure it defers to the next auth
@@ -3,16 +3,19 @@ Test rooms API endpoints in the Meet core app: retrieve.
""" """
import random import random
from datetime import datetime, timedelta, timezone
from unittest import mock from unittest import mock
from django.conf import settings as django_settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.test.utils import override_settings from django.test.utils import override_settings
import jwt
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
from ...models import RoomAccessLevel from ...models import RoleChoices, RoomAccessLevel
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -31,7 +34,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"configuration": {}, "configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
} }
@@ -51,7 +53,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"configuration": {}, "configuration": {},
"access_level": "trusted", "access_level": "trusted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
} }
@@ -70,7 +71,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"configuration": {}, "configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
} }
@@ -87,7 +87,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"configuration": {}, "configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
} }
@@ -104,7 +103,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"configuration": {}, "configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
} }
@@ -214,7 +212,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"configuration": {}, "configuration": {},
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"livekit": { "livekit": {
"url": "test_url_value", "url": "test_url_value",
"room": expected_name, "room": expected_name,
@@ -261,7 +258,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"configuration": {"can_publish_sources": ["camera"]}, "configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"livekit": { "livekit": {
"url": "test_url_value", "url": "test_url_value",
"room": expected_name, "room": expected_name,
@@ -278,7 +274,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
username=None, username=None,
color=None, color=None,
sources=["camera"], sources=["camera"],
is_admin_or_owner=False, role=None,
participant_id=None, participant_id=None,
) )
@@ -313,7 +309,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"configuration": {}, "configuration": {},
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"livekit": { "livekit": {
"url": "test_url_value", "url": "test_url_value",
"room": expected_name, "room": expected_name,
@@ -330,7 +325,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
username=None, username=None,
color=None, color=None,
sources=None, sources=None,
is_admin_or_owner=False, role=None,
participant_id=None, participant_id=None,
) )
@@ -355,7 +350,6 @@ def test_api_rooms_retrieve_authenticated():
"configuration": {}, "configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
} }
@@ -401,7 +395,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"configuration": {"can_publish_sources": ["camera"]}, "configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": False,
"livekit": { "livekit": {
"url": "test_url_value", "url": "test_url_value",
"room": expected_name, "room": expected_name,
@@ -418,7 +411,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
username=None, username=None,
color=None, color=None,
sources=["camera"], sources=["camera"],
is_admin_or_owner=False, role=str(RoleChoices.MEMBER),
participant_id=None, participant_id=None,
) )
@@ -493,7 +486,6 @@ def test_api_rooms_retrieve_administrators(
assert content_dict == { assert content_dict == {
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": True,
"configuration": {}, "configuration": {},
"livekit": { "livekit": {
"url": "test_url_value", "url": "test_url_value",
@@ -511,6 +503,43 @@ def test_api_rooms_retrieve_administrators(
username=None, username=None,
color=None, color=None,
sources=None, sources=None,
is_admin_or_owner=True, role=str(user_access.role),
participant_id=None, participant_id=None,
) )
def generate_user_access_token(user):
"""Generate a valid user access JWT signed with the token secret."""
now = datetime.now(timezone.utc)
payload = {
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
"scope": "user:access",
}
return jwt.encode(
payload,
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
def test_api_rooms_retrieve_authenticated_with_user_access_token():
"""A user access token should retrieve a room exactly like a session would."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
assert response.status_code == 200
assert response.data["id"] == str(room.id)
# Authenticated as the owner: privileged fields are included
assert response.data["pin_code"] == room.pin_code
@@ -110,7 +110,7 @@ def test_start_subtitle_invalid_token():
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{}, {},
HTTP_AUTHORIZATION="Bearer invalid-token", HTTP_AUTHORIZATION="X-LiveKit-Token invalid-token",
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -128,7 +128,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token):
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{}, {},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
) )
assert response.status_code == 404 assert response.status_code == 404
@@ -148,7 +148,7 @@ def test_start_subtitle_valid_token(
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{}, {},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -178,7 +178,7 @@ def test_start_subtitle_twirp_error(
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{}, {},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
) )
assert response.status_code == 500 assert response.status_code == 500
@@ -198,7 +198,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{}, {},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -219,10 +219,13 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{}, {},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}", HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
) )
assert response.status_code == 403 assert response.status_code == 403
assert response.json() == { assert response.json() == {
"detail": "Invalid LiveKit token: Signature verification failed" "detail": "Invalid LiveKit token: Signature verification failed"
} }
# todo - try to pass another scheme to make sure it defers to the next auth
@@ -3,8 +3,12 @@ Test rooms API endpoints in the Meet core app: update.
""" """
import random import random
from datetime import datetime, timedelta, timezone
from unittest.mock import patch from unittest.mock import patch
from django.conf import settings as django_settings
import jwt
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -437,3 +441,45 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
"configuration": {"can_publish_sources": ["camera"]}, "configuration": {"can_publish_sources": ["camera"]},
}, },
) )
def generate_user_access_token(user):
"""Generate a valid user access JWT signed with the token secret."""
now = datetime.now(timezone.utc)
payload = {
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
"scope": "user:access",
}
return jwt.encode(
payload,
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
def test_api_rooms_update_authenticated_with_user_access_token():
"""Role-based permissions apply unchanged with a user access token."""
user = UserFactory()
room = RoomFactory(users=[(user, "member")])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
# A simple member cannot update the room
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
assert response.status_code == 403
# An administrator can
room.accesses.filter(user=user).update(role="administrator")
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "new name"
@@ -0,0 +1,319 @@
"""
Test rooms API endpoints in the Meet core app: update-participant-role.
"""
# pylint: disable=redefined-outer-name,unused-argument
import uuid
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import ResourceAccess, RoleChoices
from ...services.participants_management import ParticipantNotFoundException
pytestmark = pytest.mark.django_db
def test_update_participant_role_anonymous():
"""Anonymous requesters are rejected."""
client = APIClient()
room = RoomFactory()
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "administrator"},
format="json",
)
assert response.status_code == 401
def test_update_participant_role_requires_privileges():
"""A simple member cannot promote other participants."""
client = APIClient()
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.MEMBER)])
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "administrator"},
format="json",
)
assert response.status_code == 403
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_requester_not_in_meeting(mock_perm_pm):
"""An admin who is not connected to the meeting is rejected."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = False
client = APIClient()
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "administrator"},
format="json",
)
assert response.status_code == 403
mock_perm_pm.return_value.check_if_in_meeting.assert_called_once_with(
room_name=str(room.pk), identity=str(user.sub)
)
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_cannot_target_self(mock_perm_pm):
"""Requesters cannot change their own role."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
client = APIClient()
user = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": user.sub, "role": "member"},
format="json",
)
assert response.status_code == 403
assert response.json() == {"error": "You cannot change your own role."}
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_promotes_authenticated_target(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Promoting a connected, authenticated participant persists the role."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 200
assert response.json() == {
"role": "administrator",
"livekit_synced": True,
}
access = ResourceAccess.objects.get(resource=room, user=target)
assert access.role == RoleChoices.ADMIN
mock_sync.assert_called_once_with(
room_name=str(room.pk),
participant_identity=str(target.sub),
role="administrator",
)
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_demotes_authenticated_target(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Demoting a connected admin back to member updates the access row."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "member"},
format="json",
)
assert response.status_code == 200
access = ResourceAccess.objects.get(resource=room, user=target)
assert access.role == RoleChoices.MEMBER
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_cannot_demote_owner(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Room owners can never be demoted."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
client = APIClient()
admin = UserFactory()
owner = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.ADMIN), (owner, RoleChoices.OWNER)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(owner.sub), "role": "member"},
format="json",
)
assert response.status_code == 403
assert response.json() == {"error": "Room owners cannot be demoted."}
assert (
ResourceAccess.objects.get(resource=room, user=owner).role == RoleChoices.OWNER
)
mock_sync.assert_not_called()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_anonymous_target_is_ephemeral(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Promoting an anonymous participant should not be possible."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
client.force_login(admin)
anonymous_identity = uuid.uuid4()
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": anonymous_identity, "role": "administrator"},
format="json",
)
assert response.status_code == 404
assert response.json() == {
"error": "This participant has no user account and cannot be assigned a role."
}
assert not ResourceAccess.objects.filter(resource=room).exclude(user=admin).exists()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_target_not_in_meeting(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Only connected participants can be promoted or demoted."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.side_effect = (
ParticipantNotFoundException("Participant does not exist")
)
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 404
assert not ResourceAccess.objects.filter(resource=room, user=target).exists()
mock_sync.assert_not_called()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_is_idempotent_and_resyncs(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Promoting an existing admin succeeds and still re-syncs LiveKit."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 200
mock_sync.assert_called_once()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_livekit_failure_reports_partial_success(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""A LiveKit sync failure does not lose the persisted role."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = False
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 200
assert response.json() == {
"role": "administrator",
"livekit_synced": False,
}
assert (
ResourceAccess.objects.get(resource=room, user=target).role == RoleChoices.ADMIN
)
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_rejects_owner_role(mock_perm_pm):
"""The owner role can never be granted through this endpoint."""
client = APIClient()
admin = UserFactory()
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
client.force_login(admin)
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "owner"},
format="json",
)
assert response.status_code == 400
@@ -20,8 +20,9 @@ from core.services.livekit_events import (
api, api,
) )
from core.services.lobby import LobbyService from core.services.lobby import LobbyService
from core.services.room_management import RoomManagementException
from core.services.telephony import TelephonyException, TelephonyService from core.services.telephony import TelephonyException, TelephonyService
from core.utils import MetadataUpdateException, NotificationError from core.utils import NotificationError
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -70,9 +71,9 @@ def test_initialization(
), ),
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_success( def test_handle_egress_ended_success(
mock_update_room_metadata, mock_notify, mode, notification_type, service mock_update_metadata, mock_notify, mode, notification_type, service
): ):
"""Should successfully stop recording and notifies all participant.""" """Should successfully stop recording and notifies all participant."""
@@ -86,8 +87,8 @@ def test_handle_egress_ended_success(
mock_notify.assert_called_once_with( mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type} room_name=str(recording.room.id), notification_data={"type": notification_type}
) )
mock_update_room_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"] str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
) )
recording.refresh_from_db() recording.refresh_from_db()
@@ -104,9 +105,9 @@ def test_handle_egress_ended_success(
(EgressStatus.EGRESS_ABORTED, "aborted"), (EgressStatus.EGRESS_ABORTED, "aborted"),
), ),
) )
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_success( def test_handle_egress_updated_success(
mock_update_room_metadata, egress_status, status, service mock_update_metadata, egress_status, status, service
): ):
"""Should successfully update room's metadata.""" """Should successfully update room's metadata."""
@@ -117,7 +118,7 @@ def test_handle_egress_updated_success(
service._handle_egress_updated(mock_data) service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(recording.room.id), {"recording_status": status} str(recording.room.id), {"recording_status": status}
) )
@@ -129,9 +130,9 @@ def test_handle_egress_updated_success(
EgressStatus.EGRESS_LIMIT_REACHED, EgressStatus.EGRESS_LIMIT_REACHED,
), ),
) )
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_non_handled( def test_handle_egress_updated_non_handled(
mock_update_room_metadata, egress_status, service mock_update_metadata, egress_status, service
): ):
"""Should ignore certain egress status and don't trigger metadata updates.""" """Should ignore certain egress status and don't trigger metadata updates."""
@@ -142,7 +143,7 @@ def test_handle_egress_updated_non_handled(
service._handle_egress_updated(mock_data) service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_not_called() mock_update_metadata.assert_not_called()
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -153,9 +154,9 @@ def test_handle_egress_updated_non_handled(
), ),
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_metadata_update_fails( def test_handle_egress_ended_metadata_update_fails(
mock_update_room_metadata, mock_notify, mode, notification_type, service mock_update_metadata, mock_notify, mode, notification_type, service
): ):
"""Should successfully stop and save recording when metadata's update fails.""" """Should successfully stop and save recording when metadata's update fails."""
@@ -164,7 +165,7 @@ def test_handle_egress_ended_metadata_update_fails(
mock_data.egress_info.egress_id = recording.worker_id mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying") mock_update_metadata.side_effect = RoomManagementException("Error notifying")
service._handle_egress_ended(mock_data) service._handle_egress_ended(mock_data)
@@ -178,9 +179,9 @@ def test_handle_egress_ended_metadata_update_fails(
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_notification_fails( def test_handle_egress_ended_notification_fails(
mock_update_room_metadata, mock_notify, service mock_update_metadata, mock_notify, service
): ):
"""Should raise ActionFailedError when notification fails but still stop recording.""" """Should raise ActionFailedError when notification fails but still stop recording."""
@@ -200,15 +201,15 @@ def test_handle_egress_ended_notification_fails(
recording.refresh_from_db() recording.refresh_from_db()
assert recording.status == "stopped" assert recording.status == "stopped"
mock_update_room_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"] str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_found( def test_handle_egress_ended_recording_not_found(
mock_update_room_metadata, mock_notify, service mock_update_metadata, mock_notify, service
): ):
"""Should raise ActionFailedError when recording doesn't exist.""" """Should raise ActionFailedError when recording doesn't exist."""
@@ -223,16 +224,16 @@ def test_handle_egress_ended_recording_not_found(
service._handle_egress_ended(mock_data) service._handle_egress_ended(mock_data)
mock_notify.assert_not_called() mock_notify.assert_not_called()
mock_update_room_metadata.assert_not_called() mock_update_metadata.assert_not_called()
recording.refresh_from_db() recording.refresh_from_db()
assert recording.status == "active" assert recording.status == "active"
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_active( def test_handle_egress_ended_recording_not_active(
mock_update_room_metadata, mock_notify, service mock_update_metadata, mock_notify, service
): ):
"""Should ignore non-active recordings.""" """Should ignore non-active recordings."""
@@ -244,8 +245,8 @@ def test_handle_egress_ended_recording_not_active(
service._handle_egress_ended(mock_data) service._handle_egress_ended(mock_data)
mock_notify.assert_not_called() mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"] str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
) )
recording.refresh_from_db() recording.refresh_from_db()
@@ -253,9 +254,9 @@ def test_handle_egress_ended_recording_not_active(
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_limit_reached( def test_handle_egress_ended_recording_not_limit_reached(
mock_update_room_metadata, mock_notify, service mock_update_metadata, mock_notify, service
): ):
"""Should ignore egress non-limit-reached statuses.""" """Should ignore egress non-limit-reached statuses."""
@@ -267,16 +268,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
service._handle_egress_ended(mock_data) service._handle_egress_ended(mock_data)
mock_notify.assert_not_called() mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"] str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
) )
assert recording.status == "stopped" assert recording.status == "stopped"
@mock.patch("core.services.livekit_events.MetadataCollectorService") @mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met( def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
mock_update_room_metadata, mock_collector_class, service, settings mock_update_metadata, mock_collector_class, service, settings
): ):
"""Should call MetadataCollectorService.stop when it exists.""" """Should call MetadataCollectorService.stop when it exists."""
settings.METADATA_COLLECTOR_ENABLED = True settings.METADATA_COLLECTOR_ENABLED = True
@@ -306,7 +307,7 @@ def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_m
], ],
) )
@mock.patch("core.services.livekit_events.MetadataCollectorService") @mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met( def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
_, mock_collector_class, metadata_enabled, options, service, settings _, mock_collector_class, metadata_enabled, options, service, settings
): # pylint: disable=too-many-arguments,too-many-positional-arguments ): # pylint: disable=too-many-arguments,too-many-positional-arguments
@@ -335,7 +336,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
"notify_external_services" "notify_external_services"
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"egress_status", "egress_status",
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED], [EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
@@ -345,7 +346,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
[(True, "notification_succeeded"), (False, "saved")], [(True, "notification_succeeded"), (False, "saved")],
) )
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913 def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
mock_update_room_metadata, mock_update_metadata,
mock_notify, mock_notify,
mock_notify_external_services, mock_notify_external_services,
notify_return_value, notify_return_value,
@@ -378,7 +379,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
"notify_external_services" "notify_external_services"
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"egress_status, expected_status", "egress_status, expected_status",
[ [
@@ -387,7 +388,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
], ],
) )
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913 def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
mock_update_room_metadata, mock_update_metadata,
mock_notify, mock_notify,
mock_notify_external_services, mock_notify_external_services,
egress_status, egress_status,
@@ -424,9 +425,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
EgressStatus.EGRESS_ABORTED, EgressStatus.EGRESS_ABORTED,
], ],
) )
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_save_on_wrong_status( def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_room_metadata, egress_status, service, settings mock_update_metadata, egress_status, service, settings
): ):
"""Shouldn't save on invalid status.""" """Shouldn't save on invalid status."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False settings.RECORDING_STORAGE_EVENT_ENABLE = False
@@ -445,9 +446,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"] "status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
) )
@mock.patch("core.utils.update_room_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_ignores_non_savable_recording( def test_handle_egress_ended_ignores_non_savable_recording(
mock_update_room_metadata, status, service, settings mock_update_metadata, status, service, settings
): ):
"""Should handle non-savable recordings idempotently without raising. """Should handle non-savable recordings idempotently without raising.
+113 -130
View File
@@ -3,19 +3,18 @@ Test lobby service.
""" """
# pylint: disable=W0621,W0613, W0212, R0913 # pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913
import uuid import uuid
from unittest import mock from unittest import mock
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpResponse
import pytest import pytest
from core.factories import RoomFactory from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.models import RoomAccessLevel from core.models import RoleChoices, RoomAccessLevel
from core.services.lobby import ( from core.services.lobby import (
LobbyParticipant, LobbyParticipant,
LobbyParticipantNotFound, LobbyParticipantNotFound,
@@ -134,71 +133,18 @@ def test_get_cache_key(lobby_service, participant_id):
assert cache_key == expected_key assert cache_key == expected_key
def test_get_or_create_participant_id_from_cookie(lobby_service):
"""Test extracting participant ID from cookie."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
participant_id = lobby_service._get_or_create_participant_id(request)
assert participant_id == "existing-id"
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
"""Test creating new participant ID when cookie is missing."""
request = mock.Mock()
request.COOKIES = {}
participant_id = lobby_service._get_or_create_participant_id(request)
assert participant_id == "generated-id"
mock_uuid4.assert_called_once()
def test_prepare_response_existing_cookie(lobby_service, participant_id):
"""Test response preparation with existing cookie."""
response = HttpResponse()
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
lobby_service.prepare_response(response, participant_id)
# Verify cookie wasn't set again
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
assert cookie.value == "existing-cookie"
assert cookie.value != participant_id
def test_prepare_response_new_cookie(lobby_service, participant_id):
"""Test response preparation with new cookie."""
response = HttpResponse()
lobby_service.prepare_response(response, participant_id)
# Verify cookie was set
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
assert cookie is not None
assert cookie.value == participant_id
assert cookie["httponly"] is True
assert cookie["secure"] is True
assert cookie["samesite"] == "Lax"
# It's a session cookies (no max_age specified):
assert not cookie["max-age"]
def test_can_bypass_lobby_public_room(lobby_service): def test_can_bypass_lobby_public_room(lobby_service):
"""Should return True for public rooms regardless of user auth.""" """Should return True for public rooms regardless of user auth and role."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
# Anonymous user # Anonymous user
user = mock.Mock() user = mock.Mock()
user.is_authenticated = False user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is True assert lobby_service.can_bypass_lobby(room, user, role=None) is True
# Authenticated user # Authenticated user
user.is_authenticated = True user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is True assert lobby_service.can_bypass_lobby(room, user, role=None) is True
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service): def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
@@ -208,7 +154,7 @@ def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
# Authenticated user # Authenticated user
user = mock.Mock() user = mock.Mock()
user.is_authenticated = True user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is True assert lobby_service.can_bypass_lobby(room, user, role=None) is True
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service): def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
@@ -218,21 +164,34 @@ def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
# Anonymous user # Anonymous user
user = mock.Mock() user = mock.Mock()
user.is_authenticated = False user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is False assert lobby_service.can_bypass_lobby(room, user, role=None) is False
def test_can_bypass_lobby_private_room(lobby_service): def test_can_bypass_lobby_private_room(lobby_service):
"""Should return False for private rooms regardless of user auth.""" """Should return False for private rooms regardless of user auth if role is not."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Anonymous user # Anonymous user
user = mock.Mock() user = mock.Mock()
user.is_authenticated = False user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is False assert lobby_service.can_bypass_lobby(room, user, role=None) is False
# Authenticated user # Authenticated user
user.is_authenticated = True user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is False assert lobby_service.can_bypass_lobby(room, user, role=None) is False
@pytest.mark.parametrize(
"role",
[RoleChoices.MEMBER, RoleChoices.ADMIN, RoleChoices.OWNER],
)
def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
"""Should return True for private rooms if the user is authenticated and has any role."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = mock.Mock()
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user, role=role) is True
@mock.patch("core.utils.generate_livekit_config") @mock.patch("core.utils.generate_livekit_config")
@@ -241,7 +200,7 @@ def test_request_entry_public_room(
): ):
"""Test requesting entry to a public room.""" """Test requesting entry to a public room."""
request = mock.Mock() request = mock.Mock()
request.user = mock.Mock() request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -252,11 +211,12 @@ def test_request_entry_public_room(
color="#123456", color="#123456",
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant) lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"} mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username) participant, livekit_config = lobby_service.request_entry(
room, request, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"} assert livekit_config == {"token": "test-token"}
@@ -266,8 +226,8 @@ def test_request_entry_public_room(
username=username, username=username,
color=participant.color, color=participant.color,
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None,
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -279,8 +239,7 @@ def test_request_entry_trusted_room(
): ):
"""Test requesting entry to a trusted room when the user is authenticated.""" """Test requesting entry to a trusted room when the user is authenticated."""
request = mock.Mock() request = mock.Mock()
request.user = mock.Mock() request.user = UserFactory()
request.user.is_authenticated = True
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
@@ -291,11 +250,12 @@ def test_request_entry_trusted_room(
color="#123456", color="#123456",
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant) lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"} mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username) participant, livekit_config = lobby_service.request_entry(
room, request, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"} assert livekit_config == {"token": "test-token"}
@@ -305,24 +265,26 @@ def test_request_entry_trusted_room(
username=username, username=username,
color=participant.color, color=participant.color,
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None,
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.LobbyService.enter") @mock.patch("core.services.lobby.LobbyService._notify_entry_request")
@mock.patch("core.services.lobby.LobbyService._create_participant")
def test_request_entry_new_participant( def test_request_entry_new_participant(
mock_enter, lobby_service, participant_id, username mock_create, mock_notify, lobby_service, participant_id, username
): ):
"""Test requesting entry for a new participant.""" """A new participant gets a server-minted identifier - any provided
one is unknown to the lobby and therefore discarded - and the room is
notified of the entry request."""
request = mock.Mock() request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id} request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=None) lobby_service._get_participant = mock.Mock(return_value=None)
participant_data = LobbyParticipant( participant_data = LobbyParticipant(
@@ -331,14 +293,20 @@ def test_request_entry_new_participant(
id=participant_id, id=participant_id,
color="#123456", color="#123456",
) )
mock_enter.return_value = participant_data mock_create.return_value = participant_data
participant, livekit_config = lobby_service.request_entry(room, request, username) forged_id = str(uuid.uuid4())
participant, livekit_config = lobby_service.request_entry(
room, request, username, participant_id=forged_id
)
assert participant == participant_data assert participant == participant_data
assert livekit_config is None assert livekit_config is None
mock_enter.assert_called_once_with(room.id, participant_id, username) # The provided identifier was looked up, found unknown, and replaced
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) # by a freshly minted participant
lobby_service._get_participant.assert_called_once_with(room.id, forged_id)
mock_create.assert_called_once_with(room.id, username)
mock_notify.assert_called_once_with(str(room.id))
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status") @mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
@@ -347,7 +315,7 @@ def test_request_entry_waiting_participant(
): ):
"""Test requesting entry for a waiting participant.""" """Test requesting entry for a waiting participant."""
request = mock.Mock() request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id} request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -357,10 +325,11 @@ def test_request_entry_waiting_participant(
id=participant_id, id=participant_id,
color="#123456", color="#123456",
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant) lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
participant, livekit_config = lobby_service.request_entry(room, request, username) participant, livekit_config = lobby_service.request_entry(
room, request, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.WAITING assert participant.status == LobbyParticipantStatus.WAITING
assert livekit_config is None assert livekit_config is None
@@ -374,8 +343,7 @@ def test_request_entry_accepted_participant(
): ):
"""Test requesting entry for an accepted participant.""" """Test requesting entry for an accepted participant."""
request = mock.Mock() request = mock.Mock()
request.user = mock.Mock() request.user = AnonymousUser()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -385,12 +353,13 @@ def test_request_entry_accepted_participant(
id=participant_id, id=participant_id,
color="#123456", color="#123456",
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant) lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"} mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username) participant, livekit_config = lobby_service.request_entry(
room, request, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"} assert livekit_config == {"token": "test-token"}
@@ -400,8 +369,48 @@ def test_request_entry_accepted_participant(
username=username, username=username,
color="#123456", color="#123456",
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_participant_with_role(
mock_generate_config, lobby_service, participant_id, username
):
"""Test requesting entry for a participant with a role on the room."""
request = mock.Mock()
request.user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color="#123456",
)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(
room, request, username, participant_id=participant_id
)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
username=username,
color="#123456",
configuration=room.configuration,
participant_id="test-participant-id",
role="administrator",
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -417,73 +426,47 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
) )
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color") @mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants") def test_create_participant(
def test_enter_success(
mock_notify,
mock_generate_color, mock_generate_color,
mock_cache, mock_cache,
lobby_service, lobby_service,
participant_id,
username, username,
settings,
): ):
"""Test successful participant entry.""" """A created participant is waiting, colored, and persisted."""
mock_generate_color.return_value = "#123456" mock_generate_color.return_value = "#123456"
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
participant = lobby_service.enter(room.id, participant_id, username) participant = lobby_service._create_participant(room.id, username)
mock_generate_color.assert_called_once_with(participant_id) # The identifier is minted server-side
uuid.UUID(participant.id)
mock_generate_color.assert_called_once_with(participant.id)
assert participant.status == LobbyParticipantStatus.WAITING assert participant.status == LobbyParticipantStatus.WAITING
assert participant.username == username assert participant.username == username
assert participant.id == participant_id
assert participant.color == "#123456" assert participant.color == "#123456"
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id) lobby_service._get_cache_key.assert_called_once_with(room.id, participant.id)
mock_cache.set.assert_called_once_with( mock_cache.set.assert_called_once_with(
"mocked_cache_key", "mocked_cache_key",
participant.to_dict(), participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT, timeout=settings.LOBBY_WAITING_TIMEOUT,
) )
mock_notify.assert_called_once_with(
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
def test_enter_with_notification_error( def test_notify_entry_request_with_notification_error(mock_notify, lobby_service):
mock_notify, """A notification error must not break the entry request flow."""
mock_generate_color,
mock_cache,
lobby_service,
participant_id,
username,
):
"""Test participant entry with notification error."""
mock_generate_color.return_value = "#123456"
mock_notify.side_effect = NotificationError("Error notifying") mock_notify.side_effect = NotificationError("Error notifying")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) lobby_service._notify_entry_request("room-id")
participant = lobby_service.enter(room.id, participant_id, username)
mock_generate_color.assert_called_once_with(participant_id) mock_notify.assert_called_once_with(
assert participant.status == LobbyParticipantStatus.WAITING room_name="room-id", notification_data={"type": "participantWaiting"}
assert participant.username == username
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.set.assert_called_once_with(
"mocked_cache_key",
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
) )
@@ -0,0 +1,46 @@
"""
Unit tests for the TransitCodeService.
"""
import pytest
from core.factories import UserFactory
from core.services.transit_code import TransitCodeService
pytestmark = pytest.mark.django_db
def test_create_code_returns_unique_opaque_codes():
"""Each created code should be a distinct high-entropy string."""
user = UserFactory()
service = TransitCodeService()
codes = {service.create_code(user) for _ in range(5)}
assert len(codes) == 5
for code in codes:
assert len(code) >= 43
def test_consume_code_returns_stored_data_once():
"""Consuming a code should return its data exactly once."""
user = UserFactory()
service = TransitCodeService()
code = service.create_code(user, client_id="my-app")
assert service.consume_code(code) == {
"user_id": str(user.id),
"client_id": "my-app",
}
# Single use: a second consumption fails
assert service.consume_code(code) is None
def test_consume_code_unknown_or_empty():
"""Unknown or empty codes should not be consumable."""
service = TransitCodeService()
assert service.consume_code("unknown-code") is None
assert service.consume_code("") is None
assert service.consume_code(None) is None
@@ -0,0 +1,200 @@
"""
Tests for user access JWT authentication on the core API.
The token authenticates the user on the whole API, exactly like a session
cookie would (similar to lib-jitsi-meet's token authentication): the
existing role-based permissions apply unchanged. Room endpoint coverage
with a user access token lives in the room test files.
"""
from datetime import datetime, timedelta, timezone
from django.conf import settings as django_settings
import jwt
import pytest
from rest_framework.test import APIClient
from core.factories import RoomFactory, UserFactory
from core.models import RoleChoices
pytestmark = pytest.mark.django_db
def generate_user_access_token(user, **overrides):
"""Generate a valid user access JWT signed with the token secret."""
now = datetime.now(timezone.utc)
payload = {
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
"scope": "user:access",
}
payload.update(overrides)
payload = {key: value for key, value in payload.items() if value is not None}
return jwt.encode(
payload,
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
def test_user_access_token_users_me():
"""A user access token should authenticate the user on /users/me/."""
user = UserFactory()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert response.data["email"] == user.email
def test_user_access_token_expired():
"""An expired user access token should be rejected."""
user = UserFactory()
now = datetime.now(timezone.utc)
token = generate_user_access_token(
user,
iat=now - timedelta(hours=3),
exp=now - timedelta(hours=1),
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
assert "token expired" in str(response.data).lower()
def test_user_access_token_invalid_signature():
"""A token signed with the wrong key should defer and end unauthenticated."""
user = UserFactory()
now = datetime.now(timezone.utc)
token = jwt.encode(
{
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=600),
"user_id": str(user.id),
"token_type": "user_access",
"client_id": "test-app",
},
"wrong-secret-key-padded-for-minimum-len!",
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
# UserAccessJWTAuthentication defers, session auth finds no session
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
def test_user_access_token_wrong_token_type():
"""A verified token with the wrong 'token_type' claim should be rejected."""
user = UserFactory()
token = generate_user_access_token(user, token_type="addons")
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
assert "invalid token type" in str(response.data).lower()
def test_user_access_token_missing_client_id_claim():
"""A token without the issuance-audit claim should be rejected."""
user = UserFactory()
token = generate_user_access_token(user, client_id=None)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
assert "invalid token claims" in str(response.data).lower()
def test_user_access_token_inactive_user():
"""A user access token for an inactive user should be rejected."""
user = UserFactory(is_active=False)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
def test_user_access_token_feature_disabled(settings):
"""When the feature is disabled, user access tokens should be ignored."""
settings.USER_ACCESS_TOKEN_ENABLED = False
user = UserFactory()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
def test_user_access_token_does_not_break_session_authentication():
"""A session-authenticated user should keep full access to the API."""
user = UserFactory()
RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 1
def test_user_access_token_application_jwt_not_accepted_on_core_api():
"""An application-delegation JWT must not authenticate on the core API."""
user = UserFactory()
now = datetime.now(timezone.utc)
token = jwt.encode(
{
"iss": django_settings.APPLICATION_JWT_ISSUER,
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=600),
"user_id": str(user.id),
"client_id": "some-client",
"delegated": True,
"scope": "rooms:retrieve",
},
django_settings.APPLICATION_JWT_SECRET_KEY,
algorithm=django_settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
# The user token backend must defer (wrong signature) and the request
# must end up unauthenticated.
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 401
@@ -0,0 +1,165 @@
"""
Test users API endpoints in the Meet core app: exchange transit code.
"""
# pylint: disable=W0621
import secrets
import jwt
import pytest
from rest_framework.test import APIClient
from core.factories import UserFactory
from core.services.transit_code import TransitCodeService
pytestmark = pytest.mark.django_db
def decode_user_access_token(token, settings):
"""Decode a user access token with the token secret."""
return jwt.decode(
token,
settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithms=[settings.USER_ACCESS_TOKEN_ALG],
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
)
def generate_unknown_code(settings):
"""Generate a well-formed code that was never stored."""
return secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
@pytest.fixture
def client():
"""Return an anonymous API client with a random source IP.
A fresh IP per test isolates the anonymous throttle history, both
between the tests of this module and between test runs.
"""
# `secrets` rather than `random`: the global random module is seeded
# deterministically by the factories, its sequence repeats across runs.
remote_addr = (
f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
f".{secrets.randbelow(254) + 1}"
)
return APIClient(REMOTE_ADDR=remote_addr)
def test_exchange_access_token_missing_code(client):
"""The exchange endpoint should validate its input."""
response = client.post("/api/v1.0/users/exchange-access-token/")
assert response.status_code == 400
assert "code" in response.data
def test_exchange_access_token_malformed_code(client):
"""A code whose length cannot match a generated one should be a 400."""
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": "not-a-valid-code"},
)
assert response.status_code == 400
assert "invalid transit code format" in str(response.data).lower()
def test_exchange_access_token_unknown_code(client, settings):
"""A well-formed but unknown code should be denied."""
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 403
assert "invalid, expired or already used" in str(response.data).lower()
def test_exchange_access_token_success(client, settings):
"""A valid transit code should be exchangeable for an access token."""
user = UserFactory()
code = TransitCodeService().create_code(user, client_id="my-app")
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 200
assert response.data["token_type"] == settings.USER_ACCESS_TOKEN_TYPE
assert response.data["expires_in"] == settings.USER_ACCESS_TOKEN_TTL
assert response.data["scope"] == "user:access"
payload = decode_user_access_token(response.data["access_token"], settings)
assert payload["token_type"] == "user_access"
assert payload["user_id"] == str(user.id)
assert payload["client_id"] == "my-app"
assert payload["exp"] - payload["iat"] == settings.USER_ACCESS_TOKEN_TTL
def test_exchange_access_token_single_use(client):
"""A transit code should be exchangeable exactly once."""
user = UserFactory()
code = TransitCodeService().create_code(user)
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 200
# Replaying the same code must be denied
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 403
assert "invalid, expired or already used" in str(response.data).lower()
def test_exchange_access_token_inactive_user(client):
"""A code minted for a now-inactive user should be denied."""
user = UserFactory()
code = TransitCodeService().create_code(user)
user.is_active = False
user.save()
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 403
assert "no longer access" in str(response.data).lower()
def test_exchange_access_token_feature_disabled(client, settings):
"""The exchange endpoint should return 404 when the feature is disabled."""
settings.USER_ACCESS_TOKEN_ENABLED = False
user = UserFactory()
code = TransitCodeService().create_code(user)
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
assert response.status_code == 404
def test_exchange_access_token_throttled(client, settings):
"""Anonymous exchange attempts should be rate limited."""
throttle_rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
initial_rate = throttle_rates["exchange_access_token"]
# The rates dict is mutated in place: restore it explicitly, the
# `settings` fixture only rolls back attribute assignments.
throttle_rates["exchange_access_token"] = "2/minute"
try:
for _ in range(2):
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 403
response = client.post(
"/api/v1.0/users/exchange-access-token/",
{"code": generate_unknown_code(settings)},
)
assert response.status_code == 429
finally:
throttle_rates["exchange_access_token"] = initial_rate
@@ -0,0 +1,166 @@
"""
Tests for external API /users endpoints (transit codes)
"""
# pylint: disable=W0621
from datetime import datetime, timedelta, timezone
from unittest import mock
from django.conf import settings as django_settings
import jwt
import pytest
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import ApplicationFactory, UserFactory
from core.models import ApplicationScope
from core.services.transit_code import TransitCodeService
pytestmark = pytest.mark.django_db
def generate_test_token(user, scopes):
"""Generate a valid application JWT token for testing."""
now = datetime.now(timezone.utc)
scope_string = " ".join(scopes)
application = ApplicationFactory()
payload = {
"iss": django_settings.APPLICATION_JWT_ISSUER,
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now
+ timedelta(seconds=django_settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": str(application.client_id),
"scope": scope_string,
"user_id": str(user.id),
"delegated": True,
}
return jwt.encode(
payload,
django_settings.APPLICATION_JWT_SECRET_KEY,
algorithm=django_settings.APPLICATION_JWT_ALG,
)
def test_api_users_transit_code_requires_authentication():
"""Minting a transit code without authentication should return 401."""
client = APIClient()
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 401
def test_api_users_transit_code_missing_scope():
"""A token without the 'users:session' scope should be rejected."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 403
assert "users:session" in str(response.data)
def test_api_users_transit_code_success(settings):
"""A delegated user with the scope should be able to mint a transit code."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 200
assert response.data["expires_in"] == settings.TRANSIT_CODE_TTL
code = response.data["transit_code"]
# Opaque, high-entropy random string
assert len(code) == (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
# The code is stored server-side and references the delegated user
code_data = TransitCodeService().consume_code(code)
assert code_data == {
"user_id": str(user.id),
"client_id": mock.ANY,
}
def test_api_users_transit_code_with_rs_token():
"""A resource-server-authenticated user should be able to mint a code."""
user = UserFactory()
# todo - add a decorator instead
with mock.patch.object(
ResourceServerAuthentication,
"authenticate",
return_value=(user, {"scope": "users:session", "client_id": "rs-client"}),
) as mock_rs_authenticate:
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
response = client.post("/external-api/v1.0/users/transit-code/")
mock_rs_authenticate.assert_called_once()
assert response.status_code == 200
code_data = TransitCodeService().consume_code(response.data["transit_code"])
assert code_data == {
"user_id": str(user.id),
"client_id": "rs-client",
}
def test_api_users_transit_code_with_rs_token_missing_scope():
"""A resource server token without the scope should be rejected."""
user = UserFactory()
# todo - add a decorator instead
with mock.patch.object(
ResourceServerAuthentication,
"authenticate",
return_value=(user, {"scope": "rooms:list", "client_id": "rs-client"}),
):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 403
assert "users:session" in str(response.data)
def test_api_users_transit_code_feature_disabled(settings):
"""Minting a transit code should return 404 when the feature is disabled."""
settings.USER_ACCESS_TOKEN_ENABLED = False
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 404
def test_api_users_transit_code_inactive_user():
"""An inactive user should not be able to mint a transit code."""
user = UserFactory(is_active=False)
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/users/transit-code/")
assert response.status_code == 401
# todo - add a test to make sure the addon authentification doesn't allow to mint a transit token
+5
View File
@@ -37,6 +37,11 @@ external_router.register(
external_viewsets.RoomViewSet, external_viewsets.RoomViewSet,
basename="external_room", basename="external_room",
) )
external_router.register(
"users",
external_viewsets.UserViewSet,
basename="external_user",
)
urlpatterns = [ urlpatterns = [
path( path(
+11 -58
View File
@@ -31,7 +31,6 @@ from livekit.api import ( # pylint: disable=E0611
LiveKitAPI, LiveKitAPI,
SendDataRequest, SendDataRequest,
TwirpError, TwirpError,
UpdateRoomMetadataRequest,
VideoGrants, VideoGrants,
) )
@@ -66,7 +65,7 @@ def generate_token(
username: Optional[str] = None, username: Optional[str] = None,
color: Optional[str] = None, color: Optional[str] = None,
sources: Optional[List[str]] = None, sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False, role: Optional[str] = None,
participant_id: Optional[str] = None, participant_id: Optional[str] = None,
) -> str: ) -> str:
"""Generate a LiveKit access token for a user in a specific room. """Generate a LiveKit access token for a user in a specific room.
@@ -80,7 +79,7 @@ def generate_token(
If none, a value will be generated If none, a value will be generated
sources: (Optional[List[str]]): List of media sources the user can publish sources: (Optional[List[str]]): List of media sources the user can publish
If none, defaults to LIVEKIT_DEFAULT_SOURCES. If none, defaults to LIVEKIT_DEFAULT_SOURCES.
is_admin_or_owner (bool): Whether user has admin privileges role (Optional[str]): Room's access role if any
participant_id (Optional[str]): Stable identifier for anonymous users; participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous. used as identity when user.is_anonymous.
@@ -88,6 +87,7 @@ def generate_token(
str: The LiveKit JWT access token. str: The LiveKit JWT access token.
""" """
is_admin_or_owner = role in ("owner", "administrator")
if is_admin_or_owner: if is_admin_or_owner:
sources = settings.LIVEKIT_DEFAULT_SOURCES sources = settings.LIVEKIT_DEFAULT_SOURCES
@@ -128,7 +128,11 @@ def generate_token(
.with_identity(identity) .with_identity(identity)
.with_name(display_name) .with_name(display_name)
.with_attributes( .with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"} {
"color": color,
"room_role": role,
"is_authenticated": "true" if user.is_authenticated else "false",
}
) )
) )
@@ -139,7 +143,7 @@ def generate_livekit_config(
room_id: str, room_id: str,
user, user,
username: str, username: str,
is_admin_or_owner: bool, role: Optional[str] = None,
color: Optional[str] = None, color: Optional[str] = None,
configuration: Optional[dict] = None, configuration: Optional[dict] = None,
participant_id: Optional[str] = None, participant_id: Optional[str] = None,
@@ -150,7 +154,7 @@ def generate_livekit_config(
room_id: Room identifier room_id: Room identifier
user: User instance requesting access user: User instance requesting access
username: Display name in room username: Display name in room
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room. role (str): Room's access role if any
color (Optional[str]): Optional color to associate with the participant. color (Optional[str]): Optional color to associate with the participant.
configuration (Optional[dict]): Room configuration dict that can override default settings. configuration (Optional[dict]): Room configuration dict that can override default settings.
participant_id (Optional[str]): Stable identifier for anonymous users; participant_id (Optional[str]): Stable identifier for anonymous users;
@@ -173,7 +177,7 @@ def generate_livekit_config(
username=username, username=username,
color=color, color=color,
sources=sources, sources=sources,
is_admin_or_owner=is_admin_or_owner, role=role,
participant_id=participant_id, participant_id=participant_id,
), ),
} }
@@ -258,57 +262,6 @@ async def notify_participants(room_name: str, notification_data: dict):
await lkapi.aclose() await lkapi.aclose()
class MetadataUpdateException(Exception):
"""Room's metadata update fails."""
@async_to_sync
async def update_room_metadata(
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
):
"""Update LiveKit room metadata by merging new values with existing metadata.
Args:
room_name: Name of the room to update
metadata: Dictionary of metadata key-values to add/update
remove_keys: Optional list of keys to remove from existing metadata.
"""
lkapi = create_livekit_client()
try:
response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
if not response.rooms:
return
room = response.rooms[0]
existing_metadata = json.loads(room.metadata) if room.metadata else {}
if remove_keys:
for key in remove_keys:
existing_metadata.pop(key, None)
updated_metadata = {**existing_metadata, **metadata}
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
)
)
except TwirpError as e:
raise MetadataUpdateException(
f"Failed to update metadata for room {room_name}: {e}"
) from e
finally:
await lkapi.aclose()
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
+65 -5
View File
@@ -324,6 +324,7 @@ class Base(Configuration):
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ( "DEFAULT_AUTHENTICATION_CLASSES": (
"core.authentication.user_token.UserAccessJWTAuthentication",
"core.authentication.backends.SessionAuthenticationWith401", "core.authentication.backends.SessionAuthenticationWith401",
), ),
"DEFAULT_PARSER_CLASSES": [ "DEFAULT_PARSER_CLASSES": [
@@ -344,6 +345,11 @@ class Base(Configuration):
environ_name="REQUEST_ENTRY_THROTTLE_RATES", environ_name="REQUEST_ENTRY_THROTTLE_RATES",
environ_prefix=None, environ_prefix=None,
), ),
"exchange_access_token": values.Value(
default="30/minute",
environ_name="EXCHANGE_ACCESS_TOKEN_THROTTLE_RATES",
environ_prefix=None,
),
"creation_callback": values.Value( "creation_callback": values.Value(
default="600/minute", default="600/minute",
environ_name="CREATION_CALLBACK_THROTTLE_RATES", environ_name="CREATION_CALLBACK_THROTTLE_RATES",
@@ -841,11 +847,6 @@ class Base(Configuration):
environ_name="LOBBY_NOTIFICATION_TYPE", environ_name="LOBBY_NOTIFICATION_TYPE",
environ_prefix=None, environ_prefix=None,
) )
LOBBY_COOKIE_NAME = values.Value(
"lobbyParticipantId",
environ_name="LOBBY_COOKIE_NAME",
environ_prefix=None,
)
# Calendar integrations # Calendar integrations
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue( ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
@@ -953,6 +954,61 @@ class Base(Configuration):
environ_name="APPLICATION_BASE_URL", environ_name="APPLICATION_BASE_URL",
environ_prefix=None, environ_prefix=None,
) )
# User access tokens (embedded frontend / iframe support)
USER_ACCESS_TOKEN_ENABLED = values.BooleanValue(
False, environ_name="USER_ACCESS_TOKEN_ENABLED", environ_prefix=None
)
USER_ACCESS_TOKEN_SECRET_KEY = SecretFileValue(
None, environ_name="USER_ACCESS_TOKEN_SECRET_KEY", environ_prefix=None
)
USER_ACCESS_TOKEN_ALG = values.Value(
"HS256",
environ_name="USER_ACCESS_TOKEN_ALG",
environ_prefix=None,
)
USER_ACCESS_TOKEN_ISSUER = values.Value(
"lasuite-meet",
environ_name="USER_ACCESS_TOKEN_ISSUER",
environ_prefix=None,
)
USER_ACCESS_TOKEN_AUDIENCE = values.Value(
None,
environ_name="USER_ACCESS_TOKEN_AUDIENCE",
environ_prefix=None,
)
# Lifetime of the user access token obtained through the exchange
# endpoint. It never transits through a URL, so it can cover a full
# meeting (default: 2 hours).
USER_ACCESS_TOKEN_TTL = values.PositiveIntegerValue(
7200,
environ_name="USER_ACCESS_TOKEN_TTL",
environ_prefix=None,
)
# Lifetime of the single-use transit code handed to the frontend
# through a URL fragment. Kept very short by design: it must only
# survive the redirect and the exchange call.
TRANSIT_CODE_TTL = values.PositiveIntegerValue(
60,
environ_name="TRANSIT_CODE_TTL",
environ_prefix=None,
)
TRANSIT_CODE_CACHE_PREFIX = values.Value(
"transit-code",
environ_name="TRANSIT_CODE_CACHE_PREFIX",
environ_prefix=None,
)
# Number of random bytes per code (48 bytes -> 64 url-safe characters)
TRANSIT_CODE_NBYTES = values.PositiveIntegerValue(
48,
environ_name="TRANSIT_CODE_NBYTES",
environ_prefix=None,
)
USER_ACCESS_TOKEN_TYPE = values.Value(
"Bearer",
environ_name="USER_ACCESS_TOKEN_TYPE",
environ_prefix=None,
)
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when # Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public. # EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue( EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
@@ -1250,6 +1306,10 @@ class Test(Base):
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105 ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105 ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
USER_ACCESS_TOKEN_ENABLED = True
USER_ACCESS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-room" # noqa:S105
USER_ACCESS_TOKEN_AUDIENCE = "Test inc." # noqa:S105
def __init__(self): def __init__(self):
# pylint: disable=invalid-name # pylint: disable=invalid-name
self.INSTALLED_APPS += ["drf_spectacular_sidecar"] self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
+4
View File
@@ -114,6 +114,10 @@ const config: Config = {
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)', clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
}, },
}, },
overlayIn: {
from: { opacity: 0 },
to: { opacity: 0.6 },
},
}, },
tokens: defineTokens({ tokens: defineTokens({
/* we take a few things from the panda preset but for now we clear out some stuff. /* we take a few things from the panda preset but for now we clear out some stuff.
+24 -17
View File
@@ -12,6 +12,7 @@ import { routes } from './routes'
import './i18n/init' import './i18n/init'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization' import { AppInitialization } from '@/components/AppInitialization'
import { TransitCodeGate } from '@/features/auth/components/TransitCodeGate'
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext' import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts' import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
@@ -24,23 +25,29 @@ function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{!isSDKContext && <AppInitialization />} <TransitCodeGate>
<Suspense fallback={null}> {!isSDKContext && <AppInitialization />}
<I18nProvider locale={i18n.language}> <Suspense fallback={null}>
<Layout> <I18nProvider locale={i18n.language}>
<Switch> <Layout>
{Object.entries(routes).map(([, route], i) => ( <Switch>
<Route key={i} path={route.path} component={route.Component} /> {Object.entries(routes).map(([, route], i) => (
))} <Route
<Route component={NotFoundScreen} /> key={i}
</Switch> path={route.path}
</Layout> component={route.Component}
<ReactQueryDevtools />
initialIsOpen={false} ))}
buttonPosition="bottom-left" <Route component={NotFoundScreen} />
/> </Switch>
</I18nProvider> </Layout>
</Suspense> <ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-left"
/>
</I18nProvider>
</Suspense>
</TransitCodeGate>
</QueryClientProvider> </QueryClientProvider>
) )
} }
+6
View File
@@ -1,17 +1,23 @@
import { ApiError } from './ApiError' import { ApiError } from './ApiError'
import { apiUrl } from './apiUrl' import { apiUrl } from './apiUrl'
import { getAccessToken } from '@/stores/accessToken'
export const fetchApi = async <T = Record<string, unknown>>( export const fetchApi = async <T = Record<string, unknown>>(
url: string, url: string,
options?: RequestInit options?: RequestInit
): Promise<T> => { ): Promise<T> => {
const csrfToken = getCsrfToken() const csrfToken = getCsrfToken()
// Embedded (iframe) mode: the user access token obtained through the
// transit code exchange authenticates requests in place of the session
// cookie, which is blocked in third-party contexts.
const accessToken = getAccessToken()
const response = await fetch(apiUrl(url), { const response = await fetch(apiUrl(url), {
credentials: 'include', credentials: 'include',
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(!!csrfToken && { 'X-CSRFToken': csrfToken }), ...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
...(!!accessToken && { Authorization: `Bearer ${accessToken}` }),
...options?.headers, ...options?.headers,
}, },
}) })
+12 -3
View File
@@ -28,6 +28,15 @@ const avatar = cva({
}, },
}) })
const getInitials = (name?: string): string => {
if (!name) return ''
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return ''
const first = words[0].charAt(0)
const second = words.length > 1 ? words[1].charAt(0) : ''
return (first + second).toUpperCase()
}
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & { export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
name?: string name?: string
bgColor?: string bgColor?: string
@@ -35,7 +44,7 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
export const Avatar = React.memo( export const Avatar = React.memo(
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => { ({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
const initial = name?.trim()?.charAt(0) ?? '' const initials = getInitials(name)
return ( return (
<div <div
style={{ backgroundColor: bgColor, ...style }} style={{ backgroundColor: bgColor, ...style }}
@@ -52,11 +61,11 @@ export const Avatar = React.memo(
y="50" y="50"
textAnchor="middle" textAnchor="middle"
dominantBaseline="central" dominantBaseline="central"
fontSize="52" fontSize={initials.length > 1 ? 48 : 52}
fontWeight="500" fontWeight="500"
fill="currentColor" fill="currentColor"
> >
{initial} {initials.toUpperCase()}
</text> </text>
</svg> </svg>
</div> </div>
@@ -0,0 +1,64 @@
import { fetchApi } from '@/api/fetchApi'
import { setAccessToken } from '@/stores/accessToken'
import { consumeTransitCodeFromFragment } from '../utils/transitCode'
type ApiAccessToken = {
access_token: string
token_type: string
expires_in: number
scope: string
}
/**
* Exchange a single-use transit code for a user access token.
*
* The endpoint is unauthenticated: the code itself is the credential.
*/
export const exchangeAccessToken = (code: string): Promise<ApiAccessToken> => {
return fetchApi<ApiAccessToken>('/users/exchange-access-token/', {
method: 'POST',
body: JSON.stringify({ code }),
})
}
const runInitialization = async (): Promise<void> => {
const code = consumeTransitCodeFromFragment()
if (!code) {
return
}
try {
const { access_token } = await exchangeAccessToken(code)
setAccessToken(access_token)
} catch (error) {
console.warn('Transit code exchange failed:', error)
}
}
let initialization: Promise<void> | null = null
/**
* Bootstrap the embedded (iframe) authentication, if applicable.
*
* When, and only when, a transit code is present in the URL fragment,
* exchange it for a user access token and keep it in the in-memory
* accessToken store: fetchApi then sends it as a Bearer header on every
* api call, authenticating the user exactly like a session cookie would.
*
* Must complete before anything fires an authenticated query, which the
* TransitCodeGate component guarantees by gating the app tree on it.
*
* Memoized: the fragment is consumed and the code exchanged exactly once,
* however many times this is called (StrictMode double-invoked effects,
* among others). Subsequent calls await the same promise.
*
* A failed exchange (expired or already used code) is not fatal: the app
* starts unauthenticated, falling back to the regular session flow.
*/
export const initializeAccessTokenFromFragment = (): Promise<void> => {
if (!initialization) {
initialization = runInitialization()
}
return initialization
}
@@ -2,6 +2,7 @@ import { ApiError } from '@/api/ApiError'
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { type ApiUser } from './ApiUser' import { type ApiUser } from './ApiUser'
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin' import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
import { getAccessToken } from '@/stores/accessToken'
/** /**
* fetch the logged-in user from the api. * fetch the logged-in user from the api.
@@ -25,7 +26,13 @@ export const fetchUser = (
if (error instanceof ApiError && error.statusCode === 401) { if (error instanceof ApiError && error.statusCode === 401) {
// make sure to not resolve the promise while trying to silent login // make sure to not resolve the promise while trying to silent login
// so that consumers of fetchUser don't think the work already ended // so that consumers of fetchUser don't think the work already ended
if (opts.attemptSilent && canAttemptSilentLogin()) { // Never attempt a silent login in embedded (token) mode: an OIDC
// redirect inside the iframe would break the embed.
if (
opts.attemptSilent &&
!getAccessToken() &&
canAttemptSilentLogin()
) {
attemptSilentLogin(30) attemptSilentLogin(30)
} else { } else {
resolve(false) resolve(false)
@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react'
import { LoadingScreen } from '@/components/LoadingScreen'
import { useHash } from '@/hooks/useHash'
import { initializeAccessTokenFromFragment } from '../api/exchangeAccessToken'
import { hasTransitCodeInFragment } from '../utils/transitCode'
/**
* Gates the app tree on the embedded (iframe) authentication bootstrap.
*
* Without a transit code in the URL fragment the overwhelmingly common
* case the component early returns children synchronously: no state,
* no effect, no extra render, no loading screen.
*
* When a transit code is present, children are not mounted until it has
* been exchanged for a user access token, so that every authenticated
* query already carries the Authorization header. A loading screen is
* displayed in the meantime, as UserAware does.
*/
export const TransitCodeGate = ({
children,
}: {
children: React.ReactNode
}) => {
const hash = useHash()
// Latch the decision on the initial hash: the bootstrap scrubs the
// fragment as soon as it starts, and the gate must not flip back to the
// fast path while the exchange is still in flight.
const [needsExchange] = useState(() => hasTransitCodeInFragment(hash))
if (!needsExchange) {
return children
}
return <TransitCodeExchange>{children}</TransitCodeExchange>
}
/**
* Only ever mounted when a transit code is present: runs the memoized
* bootstrap (safe against StrictMode double-invoked effects) and holds
* children back until it settles.
*/
const TransitCodeExchange = ({ children }: { children: React.ReactNode }) => {
const [isReady, setIsReady] = useState(false)
useEffect(() => {
let isMounted = true
initializeAccessTokenFromFragment().finally(() => {
console.log('$$ transit code exchange finished')
if (isMounted) {
console.log('$$ setIsReady')
setIsReady(true)
}
})
return () => {
isMounted = false
}
}, [])
console.log('$$ isReady', isReady)
return isReady ? (
children
) : (
<LoadingScreen header={false} footer={false} delay={1000} />
)
}
@@ -0,0 +1,46 @@
const TRANSIT_CODE_FRAGMENT_PARAM = 'transit_code'
/**
* Whether a URL fragment carries a transit code. Pure check, does not
* consume anything.
*/
export const hasTransitCodeInFragment = (hash: string): boolean => {
if (!hash) {
return false
}
return new URLSearchParams(hash.replace(/^#/, '')).has(
TRANSIT_CODE_FRAGMENT_PARAM
)
}
/**
* Extract the transit code from the URL fragment, if any.
*
* The fragment is scrubbed from the address bar immediately, before any
* network call, so the code never lingers in the browser history. Any
* other fragment content is preserved.
*/
export const consumeTransitCodeFromFragment = (): string | null => {
if (typeof window === 'undefined' || !window.location.hash) {
return null
}
const params = new URLSearchParams(window.location.hash.substring(1))
const code = params.get(TRANSIT_CODE_FRAGMENT_PARAM)
if (!code) {
return null
}
params.delete(TRANSIT_CODE_FRAGMENT_PARAM)
const remaining = params.toString()
window.history.replaceState(
null,
'',
window.location.pathname +
window.location.search +
(remaining ? `#${remaining}` : '')
)
return code
}
@@ -3,7 +3,7 @@ import { ref } from 'valtio'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useChat, useRoomContext } from '@livekit/components-react' import { useChat, useRoomContext } from '@livekit/components-react'
import { appendRow, chatStore } from '@/stores/chat' import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
import type { ChatMessage } from '@livekit/components-core' import type { ChatMessage } from '@livekit/components-core'
import { import {
LocalParticipant, LocalParticipant,
@@ -19,6 +19,10 @@ export const ChatProvider = () => {
const room = useRoomContext() const room = useRoomContext()
useEffect(() => {
resetChatStore()
}, [])
// Tigger the message notification (temporary) // Tigger the message notification (temporary)
useEffect(() => { useEffect(() => {
// TEMPORARY: This is a brittle workaround that relies on message count tracking // TEMPORARY: This is a brittle workaround that relies on message count tracking
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from 'react'
import { useSnapshot } from 'valtio'
import { accessTokenStore } from '@/stores/accessToken'
import { resolveMediaUrl } from '../utils/resolveMediaUrl'
/**
* Reactive companion of resolveMediaUrl for browser-native consumers
* (CSS url(), img src attributes): resolves a list of /media/ URLs and
* returns a stable lookup, identity in regular mode.
*
* Object URLs come from the shared session-lifetime cache and are never
* revoked here: they may be used concurrently by the background
* processors.
*/
export const useResolvedMediaUrls = (
urls: (string | null | undefined)[]
): ((url: string) => string) => {
const [resolved, setResolved] = useState<Record<string, string>>({})
const { accessToken } = useSnapshot(accessTokenStore)
// Stable dependency for the effect, insensitive to array identity
const urlsKey = urls.filter(Boolean).sort().join('\n')
useEffect(() => {
if (!accessToken || !urlsKey) {
return
}
let isMounted = true
const resolveAll = async () => {
const entries = await Promise.all(
urlsKey.split('\n').map(async (url) => {
try {
return [url, await resolveMediaUrl(url)] as const
} catch (error) {
console.warn(error)
return [url, url] as const
}
})
)
if (isMounted) {
setResolved(Object.fromEntries(entries))
}
}
resolveAll()
return () => {
isMounted = false
}
}, [accessToken, urlsKey])
// Stable identity so that consumers can safely list the resolver in
// their memo dependencies: it only changes when resolutions land.
return useCallback((url: string) => resolved[url] ?? url, [resolved])
}
@@ -0,0 +1,47 @@
import { getAccessToken } from '@/stores/accessToken'
// Session-lifetime cache: object URLs are shared between every consumer
// of a given media (background processors, thumbnails) and are therefore
// never revoked - their number is bounded by the user's custom
// backgrounds, and they die with the page like the access token does.
const objectUrlCache = new Map<string, string>()
/**
* Resolve an authenticated /media/ URL for the embedded (token) mode.
*
* Media files are served behind an nginx auth_request subrequest that
* authenticates the original request. In regular mode the session cookie
* rides along browser-native loads (img.src, CSS url()) and the URL is
* returned unchanged, without any fetch. In embedded mode the
* third-party cookie is blocked and native loads cannot carry the
* Authorization header, so the media is fetched here with the Bearer
* header - which the media-auth endpoint accepts, as it sits behind the
* default authentication stack - and exposed as a blob object URL.
*/
export const resolveMediaUrl = async (url: string): Promise<string> => {
const accessToken = getAccessToken()
if (!accessToken) {
return url
}
const cached = objectUrlCache.get(url)
if (cached) {
return cached
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) {
throw new Error(
`Failed to resolve media url ${url}: HTTP ${response.status}`
)
}
const objectUrl = URL.createObjectURL(await response.blob())
objectUrlCache.set(url, objectUrl)
return objectUrl
}
@@ -11,6 +11,7 @@ import { PaginationIndicator } from './PaginationIndicator'
import { useGridLayout } from '../hooks/useGridLayout' import { useGridLayout } from '../hooks/useGridLayout'
import { PaginationControl } from './PaginationControl' import { PaginationControl } from './PaginationControl'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
interface GridLayoutObserverProps { interface GridLayoutObserverProps {
gridEl: React.RefObject<HTMLDivElement> gridEl: React.RefObject<HTMLDivElement>
@@ -74,6 +75,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
[props] [props]
) )
const pagination = usePagination(maxTiles, tracks) const pagination = usePagination(maxTiles, tracks)
useSpeakerPromotionTrigger(pagination.tracks)
useSwipe(gridEl, { useSwipe(gridEl, {
onLeftSwipe: pagination.nextPage, onLeftSwipe: pagination.nextPage,
@@ -9,7 +9,7 @@ import {
log, log,
type TrackReferenceOrPlaceholder, type TrackReferenceOrPlaceholder,
} from '@livekit/components-core' } from '@livekit/components-core'
import { RoomEvent, Track } from 'livekit-client' import { Track } from 'livekit-client'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout' import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
@@ -23,7 +23,7 @@ export const StageLayout = () => {
{ source: Track.Source.Camera, withPlaceholder: true }, { source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false }, { source: Track.Source.ScreenShare, withPlaceholder: false },
], ],
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false } { updateOnlyOn: [], onlySubscribed: false }
) )
const screenShareTracks = tracks const screenShareTracks = tracks
@@ -104,7 +104,7 @@ export const StageLayout = () => {
> >
<ParticipantTile /> <ParticipantTile />
</CarouselLayout> </CarouselLayout>
{pinnedTrackRef && <FocusLayout trackRef={pinnedTrackRef} />} <FocusLayout trackRef={pinnedTrackRef} />
</FocusLayoutContainer> </FocusLayoutContainer>
</div> </div>
)} )}
@@ -0,0 +1,48 @@
import { RoomEvent } from 'livekit-client'
import type { Participant } from 'livekit-client'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useRoomContext } from '@livekit/components-react'
import { useEffect, useReducer, useRef } from 'react'
/**
* Tripwire for speaker promotion.
*
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
* re-render of the host component only when an active speaker has none of
* their tiles are within the visible span (`maxVisibleTiles`). That render
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
* state and performs the actual swap.
*
* Speakers already visible are ignored. Everything else costs zero React work.
*
* Requires the parent to NOT re-render on speaker events itself, i.e.
* `useTracks(..., { updateOnlyOn: [] })` upstream.
*/
export function useSpeakerPromotionTrigger(
sortedTiles: TrackReferenceOrPlaceholder[]
) {
const room = useRoomContext()
const [, forceRender] = useReducer((n: number) => n + 1, 0)
// Refs so the listener reads current values without re-subscribing
// and without itself being a render dependency.
const tilesRef = useRef(sortedTiles)
tilesRef.current = sortedTiles
useEffect(() => {
const onActiveSpeakersChanged = (speakers: Participant[]) => {
const tiles = tilesRef.current
const hiddenSpeakerExists = speakers.some(
(speaker) =>
!tiles.some((t) => t.participant.identity === speaker.identity)
)
if (hiddenSpeakerExists) {
forceRender()
}
}
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
return () => {
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
}
}, [room])
}
@@ -168,6 +168,31 @@ export const MainNotificationToast = () => {
} }
}, [room, triggerNotificationSoundIfRoomIsSmall]) }, [room, triggerNotificationSoundIfRoomIsSmall])
useEffect(() => {
const handleAttributeChanged = (
changedAttributes: Record<string, string>,
participant: Participant
) => {
if (!participant.isLocal || !('room_role' in changedAttributes)) return
const newRole = changedAttributes['room_role']
toastQueue.add(
{
participant,
type: NotificationType.RoleChanged,
newRole: newRole,
},
{
timeout: NotificationDuration.ROLE_CHANGED,
}
)
}
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
return () => {
room.off(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
}
}, [room])
useEffect(() => { useEffect(() => {
const removeParticipantNotifications = (participant: Participant) => { const removeParticipantNotifications = (participant: Participant) => {
toastQueue.visibleToasts.forEach((toast) => { toastQueue.visibleToasts.forEach((toast) => {
@@ -14,4 +14,5 @@ export const NotificationDuration = {
RECORDING_SAVING: ToastDuration.EXTRA_LONG, RECORDING_SAVING: ToastDuration.EXTRA_LONG,
REACTION_RECEIVED: ToastDuration.SHORT, REACTION_RECEIVED: ToastDuration.SHORT,
RECORDING_REQUESTED: ToastDuration.LONG, RECORDING_REQUESTED: ToastDuration.LONG,
ROLE_CHANGED: ToastDuration.LONG,
} as const } as const
@@ -17,4 +17,5 @@ export enum NotificationType {
ScreenRecordingLimitReached = 'screenRecordingLimitReached', ScreenRecordingLimitReached = 'screenRecordingLimitReached',
RecordingSaving = 'recordingSaving', RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved', PermissionsRemoved = 'permissionsRemoved',
RoleChanged = 'roleChanged',
} }
@@ -14,6 +14,7 @@ import { ToastRecordingSaving } from './ToastRecordingSaving'
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved' import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
import { ToastRecordingRequest } from './ToastRecordingRequest' import { ToastRecordingRequest } from './ToastRecordingRequest'
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom' import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
interface ToastRegionProps extends AriaToastRegionProps { interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData> state: ToastState<ToastData>
@@ -70,6 +71,9 @@ const renderToast = (
<ToastRecordingSaving key={toast.key} toast={toast} state={state} /> <ToastRecordingSaving key={toast.key} toast={toast} state={state} />
) )
case NotificationType.RoleChanged:
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
default: default:
return <Toast key={toast.key} toast={toast} state={state} /> return <Toast key={toast.key} toast={toast} state={state} />
} }
@@ -0,0 +1,28 @@
import { useToast } from 'react-aria'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer'
export function ToastRoleChanged({ state, ...props }: Readonly<ToastProps>) {
const { t } = useTranslation('notifications', { keyPrefix: 'roleChanged' })
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const newRole = t(`roles.${props.toast.content.newRole}`)
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t('body', { role: newRole })}
</HStack>
</StyledToastContainer>
)
}
@@ -3,7 +3,6 @@ import {
LockLockedIcon, LockLockedIcon,
ScreenShareIcon, ScreenShareIcon,
useIsEncrypted, useIsEncrypted,
useParticipantInfo,
} from '@livekit/components-react' } from '@livekit/components-react'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { Participant } from 'livekit-client' import { Participant } from 'livekit-client'
@@ -12,13 +11,14 @@ import { ParticipantName } from './ParticipantName'
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper' import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
export const ParticipantMetadata = ({ export const ParticipantMetadata = ({
displayedName,
participant, participant,
isScreenShare, isScreenShare,
}: { }: {
displayedName: string
participant: Participant participant: Participant
isScreenShare: boolean isScreenShare: boolean
}) => { }) => {
const { identity, name } = useParticipantInfo({ participant })
const isEncrypted = useIsEncrypted(participant) const isEncrypted = useIsEncrypted(participant)
return ( return (
@@ -42,7 +42,7 @@ export const ParticipantMetadata = ({
)} )}
<div className="lk-participant-name-wrapper"> <div className="lk-participant-name-wrapper">
<ParticipantName <ParticipantName
displayedName={name != '' ? name : identity} displayedName={displayedName}
isScreenShare={isScreenShare} isScreenShare={isScreenShare}
/> />
</div> </div>
@@ -1,10 +1,7 @@
import type { Participant } from 'livekit-client'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar' import { Avatar } from '@/components/Avatar'
import { useIsSpeaking } from '@livekit/components-react'
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient' import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor' import React, { useMemo } from 'react'
import { useMemo } from 'react'
const StyledParticipantPlaceHolder = styled('div', { const StyledParticipantPlaceHolder = styled('div', {
base: { base: {
@@ -23,39 +20,36 @@ const StyledAvatarWrapper = styled('div', {
aspectRatio: '1 / 1', aspectRatio: '1 / 1',
width: 'min(90cqmin, 160px)', width: 'min(90cqmin, 160px)',
fontSize: 'min(27cqmin, 48px)', fontSize: 'min(27cqmin, 48px)',
'[data-lk-speaking="true"] &': {
animation: 'pulse 1s infinite',
},
}, },
}) })
type ParticipantPlaceholderProps = { type ParticipantPlaceholderProps = {
participant: Participant color: string
displayedNamed: string
} }
export const ParticipantPlaceholder = ({ export const ParticipantPlaceholder = React.memo(
participant, ({ color, displayedNamed }: ParticipantPlaceholderProps) => {
}: ParticipantPlaceholderProps) => { const backgroundGradient = useMemo(
const isSpeaking = useIsSpeaking(participant) () => getParticipantBackgroundGradient(color),
const participantColor = getParticipantColor(participant) [color]
const backgroundGradient = useMemo( )
() => getParticipantBackgroundGradient(participantColor), return (
[participantColor] <StyledParticipantPlaceHolder
) style={{
backgroundColor: color,
return ( backgroundImage: backgroundGradient,
<StyledParticipantPlaceHolder }}
style={{
backgroundColor: participantColor,
backgroundImage: backgroundGradient,
}}
>
<StyledAvatarWrapper
style={{ animation: isSpeaking ? 'pulse 1s infinite' : undefined }}
> >
<Avatar <StyledAvatarWrapper>
name={participant.name} <Avatar name={displayedNamed} bgColor={color} context="placeholder" />
bgColor={participantColor} </StyledAvatarWrapper>
context="placeholder" </StyledParticipantPlaceHolder>
/> )
</StyledAvatarWrapper> }
</StyledParticipantPlaceHolder> )
)
} ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
@@ -1,3 +1,4 @@
import React from 'react'
import { import {
AudioTrack, AudioTrack,
ParticipantTileProps, ParticipantTileProps,
@@ -8,8 +9,8 @@ import {
VideoTrack, VideoTrack,
TrackRefContext, TrackRefContext,
ParticipantContextIfNeeded, ParticipantContextIfNeeded,
useParticipantInfo,
} from '@livekit/components-react' } from '@livekit/components-react'
import React from 'react'
import { import {
isEqualTrackRef, isEqualTrackRef,
isTrackReference, isTrackReference,
@@ -17,15 +18,15 @@ import {
} from '@livekit/components-core' } from '@livekit/components-core'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import { ParticipantPlaceholder } from './ParticipantPlaceholder' import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus' import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning' import { FullScreenShareWarning } from './FullScreenShareWarning'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog' import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels' import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
import { KeyboardShortcutHint } from './KeyboardShortcutHint' import { KeyboardShortcutHint } from './KeyboardShortcutHint'
import { layoutStore, clearPinnedTrack } from '@/stores/layout' import { layoutStore, clearPinnedTrack } from '@/stores/layout'
import { ParticipantMetadata } from './ParticipantMetadata' import { ParticipantMetadata } from './ParticipantMetadata'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
export function TrackRefContextIfNeeded( export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
@@ -90,7 +91,13 @@ export const ParticipantTile: (
const isScreenShare = trackReference.source != Track.Source.Camera const isScreenShare = trackReference.source != Track.Source.Camera
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false) const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantName = getParticipantName(trackReference.participant) const participantColor = getParticipantColor(trackReference.participant)
const { identity, name } = useParticipantInfo({
participant: trackReference.participant,
})
const participantName = name || identity || 'Unknown'
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' }) const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = { const interactiveProps = {
@@ -141,11 +148,13 @@ export const ParticipantTile: (
)} )}
<div className="lk-participant-placeholder"> <div className="lk-participant-placeholder">
<ParticipantPlaceholder <ParticipantPlaceholder
participant={trackReference.participant} color={participantColor}
displayedNamed={participantName}
/> />
</div> </div>
{!disableMetadata && ( {!disableMetadata && (
<ParticipantMetadata <ParticipantMetadata
displayedName={participantName}
isScreenShare={isScreenShare} isScreenShare={isScreenShare}
participant={trackReference.participant} participant={trackReference.participant}
/> />
@@ -1,233 +0,0 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import {
RiFullscreenLine,
RiImageCircleAiFill,
RiMicLine,
RiMicOffLine,
RiPushpin2Line,
RiUnpinLine,
} from '@remixicon/react'
import { useTrackMutedIndicator } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import {
isEqualTrackRef,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { useEffect, useRef, useState } from 'react'
import { type Participant, Track } from 'livekit-client'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { useSnapshot } from 'valtio'
import { layoutStore, setPinnedTrack, clearPinnedTrack } from '@/stores/layout'
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
const ZoomButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
trackRef,
})
if (!isFullscreenAvailable) {
return
}
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fullScreen')}
onPress={() => toggleFullScreen()}
>
<RiFullscreenLine />
</Button>
)
}
const FocusButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { pinnedTrackRef } = useSnapshot(layoutStore)
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
>
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
</Button>
)
}
const EffectsButton = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
return (
<Button
size={'sm'}
variant={'primaryTextDark'}
square
tooltip={t('effects')}
onPress={() => !isEffectsOpen && toggleEffects()}
>
<RiImageCircleAiFill />
</Button>
)
}
const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { isMuted } = useTrackMutedIndicator({
participant: participant,
source: Track.Source.Microphone,
})
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
return (
<>
<Button
isDisabled={isMuted}
size={'sm'}
variant={'primaryTextDark'}
square
onPress={() => setIsAlertOpen(true)}
tooltip={t('muteParticipant', { name })}
>
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
)
}
const MOUSE_IDLE_TIME = 3000
export const ParticipantTileFocus = ({
trackRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
hasKeyboardFocus: boolean
}) => {
const [hovered, setHovered] = useState(false)
const [opacity, setOpacity] = useState(0)
const idleTimerRef = useRef<number | null>(null)
const [isIdleRef, setIsIdleRef] = useState(false)
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
useEffect(() => {
if (isVisible) {
// Wait for next frame to ensure element is mounted
requestAnimationFrame(() => {
setOpacity(0.6)
})
} else {
setOpacity(0)
}
}, [isVisible])
const handleMouseMove = () => {
if (idleTimerRef.current) {
window.clearTimeout(idleTimerRef.current)
}
idleTimerRef.current = window.setTimeout(() => {
setIsIdleRef(true)
}, MOUSE_IDLE_TIME)
setIsIdleRef(false)
}
const participant = trackRef.participant
const isScreenShare = trackRef.source == Track.Source.ScreenShare
const isLocal = trackRef.participant.isLocal
const canMute = useCanMute(participant)
return (
<div
className={css({
position: 'absolute',
left: '0',
top: '0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
})}
aria-hidden={!isVisible}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove}
>
{isVisible && (
<div
className={css({
backgroundColor: 'primaryDark.50',
transition: 'opacity 200ms linear',
zIndex: 1,
borderRadius: '0.25rem',
display: 'flex',
_hover: {
opacity: '0.95 !important',
},
})}
style={{ opacity }}
>
<HStack
gap={0.5}
className={css({
padding: '0.5rem',
_hover: {
opacity: '1 !important',
},
})}
>
<FocusButton trackRef={trackRef} />
{!isScreenShare ? (
<>
{participant.isLocal ? (
<EffectsButton />
) : (
canMute && <MuteButton participant={participant} />
)}
</>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)}
</HStack>
</div>
)}
</div>
)
}
@@ -0,0 +1,23 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { Button } from '@/primitives'
import { RiImageCircleAiFill } from '@remixicon/react'
export const EffectsButton = React.memo(() => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
return (
<Button
size={'sm'}
variant={'primaryTextDark'}
square
tooltip={t('effects')}
onPress={() => !isEffectsOpen && toggleEffects()}
>
<RiImageCircleAiFill />
</Button>
)
})
EffectsButton.displayName = 'EffectsButton'
@@ -0,0 +1,32 @@
import {
isEqualTrackRef,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { Button } from '@/primitives'
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
export const FocusButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { pinnedTrackRef } = useSnapshot(layoutStore)
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
>
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
</Button>
)
}
@@ -0,0 +1,45 @@
import { Participant, Track } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { useTrackMutedIndicator } from '@livekit/components-react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useState } from 'react'
import { Button } from '@/primitives'
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
export const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { isMuted } = useTrackMutedIndicator({
participant: participant,
source: Track.Source.Microphone,
})
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
return (
<>
<Button
isDisabled={isMuted}
size={'sm'}
variant={'primaryTextDark'}
square
onPress={() => setIsAlertOpen(true)}
tooltip={t('muteParticipant', { name })}
>
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
)
}
@@ -0,0 +1,112 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { Track } from 'livekit-client'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { FocusButton } from './FocusButton'
import { EffectsButton } from './EffectsButton'
import { MuteButton } from './MuteButton'
import { ZoomButton } from './ZoomButton'
const MOUSE_IDLE_TIME = 3000
type FadeOverlayProps = {
children: ReactNode
hasKeyboardFocus: boolean
}
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
const [active, setActive] = useState(false)
const idleTimerRef = useRef<number | null>(null)
const clearIdleTimer = () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
}
const armIdleTimer = () => {
clearIdleTimer()
idleTimerRef.current = window.setTimeout(() => {
setActive(false)
}, MOUSE_IDLE_TIME)
}
const handleActivity = () => {
setActive(true)
armIdleTimer()
}
useEffect(() => clearIdleTimer, [])
const isVisible = hasKeyboardFocus || active
return (
<div
className={css({
position: 'absolute',
left: '0',
top: '0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
})}
data-visible={isVisible || undefined}
aria-hidden={!isVisible}
onMouseEnter={handleActivity}
onMouseMove={handleActivity}
onMouseLeave={() => {
clearIdleTimer()
setActive(false)
}}
>
{isVisible && children}
</div>
)
}
export const ParticipantTileFocus = ({
trackRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
hasKeyboardFocus: boolean
}) => {
const participant = trackRef.participant
const isScreenShare = trackRef.source == Track.Source.ScreenShare
const isLocal = participant.isLocal
const canMute = useCanMute(participant)
return (
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
<div
className={css({
backgroundColor: 'primaryDark.50',
zIndex: 1,
borderRadius: '0.25rem',
display: 'flex',
opacity: 0.6,
animation: 'overlayIn 200ms linear 300ms backwards',
_hover: {
opacity: 0.95,
},
})}
>
<HStack gap={0.5} padding={0.5}>
<FocusButton trackRef={trackRef} />
{!isScreenShare ? (
<>
{isLocal ? (
<EffectsButton />
) : (
canMute && <MuteButton participant={participant} />
)}
</>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)}
</HStack>
</div>
</FadeOverlay>
)
}
@@ -0,0 +1,32 @@
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
import { Button } from '@/primitives'
import { RiFullscreenLine } from '@remixicon/react'
export const ZoomButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
trackRef,
})
if (!isFullscreenAvailable) {
return
}
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fullScreen')}
onPress={() => toggleFullScreen()}
>
<RiFullscreenLine />
</Button>
)
}
@@ -0,0 +1,31 @@
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
export const useParticipantRole = () => {
const data = useRoomData()
const updateParticipantRole = async (
identity: string,
role: AssignableParticipantRole
) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
try {
return fetchApi(`rooms/${data.id}/update-participant-role/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: identity,
role: role,
}),
})
} catch (error) {
console.error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
return { updateParticipantRole }
}
@@ -0,0 +1,68 @@
import React, { useLayoutEffect, useRef, useState } from 'react'
import { Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
export const ParticipantName = React.memo(
({ displayedName, isLocal }: { displayedName: string; isLocal: boolean }) => {
const { t } = useTranslation('rooms')
const nameRef = useRef<HTMLParagraphElement>(null)
const [isTruncated, setIsTruncated] = useState(false)
useLayoutEffect(() => {
const el = nameRef.current
if (!el) return
const truncated = el.scrollWidth > el.clientWidth
setIsTruncated((prev) => (prev === truncated ? prev : truncated))
}, [displayedName])
return (
<Text
as="div"
variant="sm"
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
minWidth: 0,
width: 'full',
maxWidth: 'full',
})}
>
<VisualOnlyTooltip
tooltip={displayedName}
disabled={!isTruncated}
className={css({ display: 'flex', minWidth: 0, flex: 1 })}
>
<p
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'block',
minWidth: 0,
})}
ref={nameRef}
>
{displayedName}
</p>
</VisualOnlyTooltip>
{isLocal && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
flexShrink: 0,
})}
>
({t('participants.you')})
</span>
)}
</Text>
)
}
)
ParticipantName.displayName = 'ParticipantName'
@@ -5,7 +5,11 @@ import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar' import { Avatar } from '@/components/Avatar'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor' import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin' import {
getParticipantIsRoomAdmin,
getParticipantIsRoomOwner,
getParticipantIsRoomMember,
} from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
import { type LocalParticipant, type Participant, Track } from 'livekit-client' import { type LocalParticipant, type Participant, Track } from 'livekit-client'
import { isLocal } from '@/utils/livekit' import { isLocal } from '@/utils/livekit'
import { import {
@@ -20,7 +24,9 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from './menu/ParticipantMenuButton' import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
import { PinBadge } from './PinBadge' import { PinBadge } from './PinBadge'
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog' import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
import { ParticipantName } from './ParticipantName'
type MicIndicatorProps = { type MicIndicatorProps = {
participant: Participant participant: Participant
@@ -106,52 +112,26 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
width: 'full', width: 'full',
})} })}
> >
<HStack> <HStack flex="1" minW="0">
<div <div className={css({ position: 'relative', flexShrink: 0 })}>
className={css({
position: 'relative',
})}
>
<Avatar name={name} bgColor={getParticipantColor(participant)} /> <Avatar name={name} bgColor={getParticipantColor(participant)} />
<PinBadge participant={participant} /> <PinBadge participant={participant} />
<UnauthenticatedBadge participant={participant} />
</div> </div>
<VStack gap={0} alignItems="start"> <VStack gap={0} alignItems="start" minW="0" flex="1">
<Text <ParticipantName
variant="sm" displayedName={name}
className={css({ isLocal={isLocal(participant)}
userSelect: 'none', />
cursor: 'default', <Text variant="xsNote">
display: 'flex', {getParticipantIsRoomOwner(participant) && t('participants.host')}
})} {getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
> {getParticipantIsRoomMember(participant) &&
<span t('participants.member')}
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text> </Text>
{getParticipantIsRoomAdmin(participant) && (
<Text variant="xsNote">{t('participants.host')}</Text>
)}
</VStack> </VStack>
</HStack> </HStack>
<HStack> <HStack flexShrink={0}>
<MicIndicator participant={participant} /> <MicIndicator participant={participant} />
<ParticipantMenuButton participant={participant} /> <ParticipantMenuButton participant={participant} />
</HStack> </HStack>
@@ -1,7 +1,6 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar' import { Avatar } from '@/components/Avatar'
import { useLowerHandParticipant } from '../api/lowerHandParticipant' import { useLowerHandParticipant } from '../api/lowerHandParticipant'
@@ -11,6 +10,7 @@ import { isLocal } from '@/utils/livekit'
import { RiHand } from '@remixicon/react' import { RiHand } from '@remixicon/react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly' import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
import { ParticipantName } from './ParticipantName'
const ActionButton = ({ const ActionButton = ({
participant, participant,
@@ -42,9 +42,7 @@ type HandRaisedListItemProps = {
} }
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => { export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity const name = participant.name || participant.identity
return ( return (
<HStack <HStack
role="listitem" role="listitem"
@@ -56,42 +54,15 @@ export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
width: 'full', width: 'full',
})} })}
> >
<HStack> <HStack flex="1" minW="0" overflow="hidden">
<Avatar name={name} bgColor={getParticipantColor(participant)} /> <Avatar name={name} bgColor={getParticipantColor(participant)} />
<Text <ParticipantName displayedName={name} isLocal={isLocal(participant)} />
variant={'sm'} </HStack>
className={css({ <HStack flexShrink={0}>
userSelect: 'none', <AdminOrOwnerOnly>
cursor: 'default', <ActionButton participant={participant} name={name} />
display: 'flex', </AdminOrOwnerOnly>
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text>
</HStack> </HStack>
<AdminOrOwnerOnly>
<ActionButton participant={participant} name={name} />
</AdminOrOwnerOnly>
</HStack> </HStack>
) )
} }
@@ -0,0 +1,43 @@
import { Participant } from 'livekit-client'
import { css } from '@/styled-system/css'
import { useParticipantAttribute } from '@livekit/components-react'
import { RiErrorWarningFill } from '@remixicon/react'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useTranslation } from 'react-i18next'
export const UnauthenticatedBadge = ({
participant,
}: {
participant: Participant
}) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'participants.unauthenticated',
})
const is_authenticated = useParticipantAttribute('is_authenticated', {
participant,
})
if (is_authenticated == 'true') return
return (
<div
className={css({
height: '18px',
width: '18px',
borderRadius: '100%',
background: 'orange.200',
color: 'orange.800',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
bottom: '-2px',
right: '-4px',
})}
>
<VisualOnlyTooltip tooltip={t('badge')}>
<RiErrorWarningFill size={14} aria-hidden />
</VisualOnlyTooltip>
</div>
)
}
@@ -1,10 +1,11 @@
import { Button, Text } from '@/primitives' import { Button } from '@/primitives'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { Avatar } from '@/components/Avatar' import { Avatar } from '@/components/Avatar'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '../api/listWaitingParticipants' import { WaitingParticipant } from '../api/listWaitingParticipants'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { ParticipantName } from './ParticipantName'
export const WaitingParticipantRow = ({ export const WaitingParticipantRow = ({
participant, participant,
@@ -26,42 +27,11 @@ export const WaitingParticipantRow = ({
width: 'full', width: 'full',
})} })}
> >
<HStack <HStack flex="1" minW="0">
className={css({
flex: '1',
minWidth: '0',
})}
>
<Avatar name={participant.username} bgColor={participant.color} /> <Avatar name={participant.username} bgColor={participant.color} />
<Text <ParticipantName displayedName={participant.username} isLocal={false} />
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
flex: '1',
minWidth: '0',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
display: 'block',
})}
>
{participant.username}
</span>
</Text>
</HStack> </HStack>
<HStack <HStack gap="0.25rem" flexShrink={0}>
gap="0.25rem"
className={css({
flexShrink: '0',
})}
>
<Button <Button
size="sm" size="sm"
variant="tertiary" variant="tertiary"
@@ -1,24 +1,46 @@
import { Menu as RACMenu } from 'react-aria-components' import { Menu as RACMenu } from 'react-aria-components'
import type { Participant } from 'livekit-client' import type { Participant } from 'livekit-client'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { PinMenuItem } from './items/PinMenuItem' import { PinMenuItem } from './items/PinMenuItem'
import { RemoveMenuItem } from './items/RemoveMenuItem' import { RemoveMenuItem } from './items/RemoveMenuItem'
import { PromoteMenuItem } from './items/PromoteMenuItem'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useParticipantAttributes } from '@livekit/components-react'
export const ParticipantMenu = ({ export const ParticipantMenu = ({
participant, participant,
}: { }: {
participant: Participant participant: Participant
}) => { }) => {
const isAdminOrOwner = useIsAdminOrOwner() const isLocalUserAdminOrOwner = useIsAdminOrOwner()
const canModerateParticipant = !participant.isLocal && isAdminOrOwner
const { attributes } = useParticipantAttributes({ participant })
const isAdmin = attributes?.room_role === 'administrator'
const isOwner = attributes?.room_role === 'owner'
const isAuthenticated = attributes?.is_authenticated == 'true'
const canManage = !participant.isLocal && isLocalUserAdminOrOwner && !isOwner
return ( return (
<RACMenu <RACMenu
style={{ style={{
minWidth: '75px', minWidth: '100px',
}} }}
> >
<PinMenuItem participant={participant} /> <PinMenuItem participant={participant} />
{canModerateParticipant && <RemoveMenuItem participant={participant} />} {canManage && (
<RemoveMenuItem
identity={participant.identity}
displayedName={participant.name}
/>
)}
{canManage && isAuthenticated && (
<PromoteMenuItem
identity={participant.identity}
isAdmin={isAdmin}
displayedName={participant.name}
/>
)}
</RACMenu> </RACMenu>
) )
} }
@@ -1,7 +1,6 @@
import { Button, Menu } from '@/primitives' import { Button, Menu } from '@/primitives'
import { RiMore2Fill } from '@remixicon/react' import { RiMore2Fill } from '@remixicon/react'
import { ParticipantMenu } from './ParticipantMenu' import { ParticipantMenu } from './ParticipantMenu'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import type { Participant } from 'livekit-client' import type { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -11,8 +10,6 @@ export const ParticipantMenuButton = ({
participant: Participant participant: Participant
}) => { }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants' }) const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
const isAdminOrOwner = useIsAdminOrOwner()
if (!isAdminOrOwner) return null
return ( return (
<Menu> <Menu>
<Button <Button
@@ -0,0 +1,47 @@
import React, { useCallback } from 'react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { RiAdminLine, RiUserMinusLine } from '@remixicon/react'
import { useParticipantRole } from '@/features/participants/api/updateParticipantRole'
import { menuRecipe } from '@/primitives/menuRecipe'
import { HStack } from '@/styled-system/jsx'
type PromoteMenuItemProps = {
identity: string
displayedName?: string
isAdmin: boolean
}
export const PromoteMenuItem = React.memo(
({ identity, displayedName, isAdmin }: PromoteMenuItemProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
const { updateParticipantRole } = useParticipantRole()
const label = isAdmin ? 'demote' : 'promote'
const Icon = isAdmin ? RiUserMinusLine : RiAdminLine
const toggleRole = useCallback(
() =>
updateParticipantRole(identity, isAdmin ? 'member' : 'administrator'),
[isAdmin, updateParticipantRole, identity]
)
return (
<MenuItem
aria-label={t(`${label}.ariaLabel`, {
name: displayedName || identity,
})}
className={menuRecipe({ icon: true }).item}
onAction={toggleRole}
>
<HStack gap={0.25} minWidth={280}>
<Icon size={20} aria-hidden />
{t(`${label}.label`)}
</HStack>
</MenuItem>
)
}
)
PromoteMenuItem.displayName = 'PromoteMenuItem'
@@ -1,4 +1,4 @@
import type { Participant } from 'livekit-client' import React from 'react'
import { menuRecipe } from '@/primitives/menuRecipe' import { menuRecipe } from '@/primitives/menuRecipe'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
@@ -6,23 +6,30 @@ import { MenuItem } from 'react-aria-components'
import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant' import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export const RemoveMenuItem = ({ type RemoveMenuItemProps = {
participant, identity: string
}: { displayedName?: string
participant: Participant
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu.remove' })
const { removeParticipant } = useRemoveParticipant()
return (
<MenuItem
aria-label={t('ariaLabel', { name: participant.name })}
className={menuRecipe({ icon: true }).item}
onAction={() => removeParticipant(participant)}
>
<HStack gap={0.25}>
<RiCloseLine size={20} aria-hidden />
{t('label')}
</HStack>
</MenuItem>
)
} }
export const RemoveMenuItem = React.memo(
({ identity, displayedName }: RemoveMenuItemProps) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'participantMenu.remove',
})
const { removeParticipant } = useRemoveParticipant()
return (
<MenuItem
aria-label={t('ariaLabel', { name: displayedName || identity })}
className={menuRecipe({ icon: true }).item}
onAction={() => removeParticipant(identity)}
>
<HStack gap={0.25}>
<RiCloseLine size={20} aria-hidden />
{t('label')}
</HStack>
</MenuItem>
)
}
)
RemoveMenuItem.displayName = 'RemoveMenuItem'
@@ -28,3 +28,6 @@ export type ApiRoom = {
livekit?: ApiLiveKit livekit?: ApiLiveKit
configuration?: RoomConfiguration configuration?: RoomConfiguration
} }
export type ParticipantRole = 'member' | 'administrator' | 'owner'
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
@@ -9,6 +9,7 @@ import { fetchApi } from '@/api/fetchApi'
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
import { useCallback } from 'react' import { useCallback } from 'react'
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
export const useMuteParticipant = () => { export const useMuteParticipant = () => {
const apiRoomData = useRoomData() const apiRoomData = useRoomData()
@@ -36,7 +37,7 @@ export const useMuteParticipant = () => {
} }
const headers = !isAdminOrOwner const headers = !isAdminOrOwner
? { Authorization: `Bearer ${apiRoomData.livekit.token}` } ? getLiveKitAuthHeaders(apiRoomData.livekit.token)
: undefined : undefined
let response let response
@@ -1,11 +1,10 @@
import type { Participant } from 'livekit-client'
import { useRoomData } from '../livekit/hooks/useRoomData' import { useRoomData } from '../livekit/hooks/useRoomData'
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
export const useRemoveParticipant = () => { export const useRemoveParticipant = () => {
const data = useRoomData() const data = useRoomData()
const removeParticipant = async (participant: Participant) => { const removeParticipant = async (identity: string) => {
if (!data?.id) { if (!data?.id) {
throw new Error('Room id is not available') throw new Error('Room id is not available')
} }
@@ -13,7 +12,7 @@ export const useRemoveParticipant = () => {
return fetchApi(`rooms/${data.id}/remove-participant/`, { return fetchApi(`rooms/${data.id}/remove-participant/`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
participant_identity: participant.identity, participant_identity: identity,
}), }),
}) })
} }
@@ -1,5 +1,6 @@
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
export const useRenameParticipant = () => { export const useRenameParticipant = () => {
const data = useRoomData() const data = useRoomData()
@@ -15,11 +16,10 @@ export const useRenameParticipant = () => {
throw new Error('LiveKit token is not available') throw new Error('LiveKit token is not available')
} }
const headers = getLiveKitAuthHeaders(token)
return fetchApi(`rooms/${data.id}/rename/`, { return fetchApi(`rooms/${data.id}/rename/`, {
method: 'POST', method: 'POST',
headers: { headers,
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: JSON.stringify({
name, name,
}), }),
@@ -1,5 +1,6 @@
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom' import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
import { getLobbyParticipantId } from '@/stores/lobby'
export interface RequestEntryParams { export interface RequestEntryParams {
roomId: string roomId: string
@@ -15,6 +16,7 @@ export enum ApiLobbyStatus {
} }
export interface ApiRequestEntry { export interface ApiRequestEntry {
id?: string
status: ApiLobbyStatus status: ApiLobbyStatus
livekit?: ApiLiveKit livekit?: ApiLiveKit
} }
@@ -23,10 +25,12 @@ export const requestEntry = async ({
roomId, roomId,
username = '', username = '',
}: RequestEntryParams) => { }: RequestEntryParams) => {
const participantId = getLobbyParticipantId(roomId)
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, { return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
username, username,
...(participantId && { participant_id: participantId }),
}), }),
}) })
} }
@@ -1,5 +1,6 @@
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
export const useRaiseHand = () => { export const useRaiseHand = () => {
const data = useRoomData() const data = useRoomData()
@@ -15,11 +16,10 @@ export const useRaiseHand = () => {
throw new Error('LiveKit token is not available') throw new Error('LiveKit token is not available')
} }
const headers = getLiveKitAuthHeaders(token)
return fetchApi(`rooms/${data.id}/toggle-hand/`, { return fetchApi(`rooms/${data.id}/toggle-hand/`, {
method: 'POST', method: 'POST',
headers: { headers,
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: JSON.stringify({
raised, raised,
}), }),
@@ -316,7 +316,7 @@ export const Join = ({
refetch: refetchRoom, refetch: refetchRoom,
} = useQuery({ } = useQuery({
queryKey: [keys.room, roomId], queryKey: [keys.room, roomId],
queryFn: () => fetchRoom({ roomId, username }), queryFn: () => fetchRoom({ roomId, username: username || user?.full_name }),
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
retry: false, retry: false,
enabled: false, enabled: false,
@@ -339,7 +339,7 @@ export const Join = ({
const { status, startWaiting } = useLobby({ const { status, startWaiting } = useLobby({
roomId, roomId,
username, username: username || user?.full_name || 'anonymous',
onAccepted: handleAccepted, onAccepted: handleAccepted,
}) })
@@ -6,6 +6,7 @@ import {
ApiLobbyStatus, ApiLobbyStatus,
type ApiRequestEntry, type ApiRequestEntry,
} from '../api/requestEntry' } from '../api/requestEntry'
import { setLobbyParticipantId } from '@/stores/lobby'
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
export const POLL_INTERVAL_MS = 1000 export const POLL_INTERVAL_MS = 1000
@@ -43,6 +44,11 @@ export const useLobby = ({
roomId, roomId,
username, username,
}) })
if (response.id) {
setLobbyParticipantId(roomId, response.id)
}
if (response.status === ApiLobbyStatus.ACCEPTED) { if (response.status === ApiLobbyStatus.ACCEPTED) {
clearWaitingTimeout() clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED) setStatus(ApiLobbyStatus.ACCEPTED)
@@ -11,6 +11,9 @@ import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter' import { useParams } from 'wouter'
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager' import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
import { usePermissionsManager } from '../hooks/usePermissionsManager' import { usePermissionsManager } from '../hooks/usePermissionsManager'
import { useEffect } from 'react'
import { closeSidePanel } from '@/stores/layout'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
export const Admin = () => { export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' }) const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -23,6 +26,14 @@ export const Admin = () => {
const { mutateAsync: patchRoom } = usePatchRoom() const { mutateAsync: patchRoom } = usePatchRoom()
const isAdminOrOwner = useIsAdminOrOwner()
useEffect(() => {
if (!isAdminOrOwner) {
closeSidePanel()
}
}, [isAdminOrOwner])
const { data: readOnlyData } = useQuery({ const { data: readOnlyData } = useQuery({
queryKey: [keys.room, roomId], queryKey: [keys.room, roomId],
queryFn: () => fetchRoom({ roomId }), queryFn: () => fetchRoom({ roomId }),
@@ -0,0 +1,6 @@
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
export const RoomMetadataSynchronizer = () => {
useSyncLiveKitMetadata()
return null
}
@@ -1,4 +1,4 @@
import { layoutStore } from '@/stores/layout' import { closeSidePanel, layoutStore } from '@/stores/layout'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { Heading } from 'react-aria-components' import { Heading } from 'react-aria-components'
import { text } from '@/primitives/Text' import { text } from '@/primitives/Text'
@@ -199,10 +199,7 @@ export const SidePanel = () => {
ref={asideRef} ref={asideRef}
title={title} title={title}
ariaLabel={t('ariaLabel', { title })} ariaLabel={t('ariaLabel', { title })}
onClose={() => { onClose={closeSidePanel}
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
closeButtonTooltip={t('closeButton', { closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`), content: t(`content.${activeSubPanelId || activePanelId}`),
})} })}
@@ -1,4 +1,5 @@
import type { ProcessorOptions, Track } from 'livekit-client' import type { ProcessorOptions, Track } from 'livekit-client'
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
import posthog from 'posthog-js' import posthog from 'posthog-js'
import { import {
FilesetResolver, FilesetResolver,
@@ -85,7 +86,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.sourceSettings = this.source!.getSettings() this.sourceSettings = this.source!.getSettings()
this.videoElement = opts.element as HTMLVideoElement this.videoElement = opts.element as HTMLVideoElement
this._initVirtualBackgroundImage() await this._initVirtualBackgroundImage()
this._createMainCanvas() this._createMainCanvas()
this._createMaskCanvas() this._createMaskCanvas()
@@ -103,7 +104,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
posthog.capture('firefox-blurring-init') posthog.capture('firefox-blurring-init')
} }
_initVirtualBackgroundImage() { async _initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') { if (this.options.type !== 'virtual') {
throw new Error( throw new Error(
'Virtual background is only supported for virtual background' 'Virtual background is only supported for virtual background'
@@ -115,15 +116,19 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.virtualBackgroundImage && this.virtualBackgroundImage &&
this.virtualBackgroundImage.src !== this.options.imagePath this.virtualBackgroundImage.src !== this.options.imagePath
if (this.options.imagePath || needsUpdate) { if (this.options.imagePath || needsUpdate) {
// Embedded (token) mode: img.src cannot carry the Authorization
// header, resolve the media to a blob object URL first. Identity
// in regular mode.
const imagePath = await resolveMediaUrl(this.options.imagePath!)
this.virtualBackgroundImage = document.createElement('img') this.virtualBackgroundImage = document.createElement('img')
this.virtualBackgroundImage.crossOrigin = 'anonymous' this.virtualBackgroundImage.crossOrigin = 'anonymous'
this.virtualBackgroundImage.src = this.options.imagePath! this.virtualBackgroundImage.src = imagePath
} }
} }
async update(opts: ProcessorConfig): Promise<void> { async update(opts: ProcessorConfig): Promise<void> {
this.options = opts this.options = opts
this._initVirtualBackgroundImage() await this._initVirtualBackgroundImage()
} }
_initWorker() { _initWorker() {
@@ -1,4 +1,5 @@
import type { ProcessorOptions, Track } from 'livekit-client' import type { ProcessorOptions, Track } from 'livekit-client'
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
import { import {
ProcessorWrapper, ProcessorWrapper,
BackgroundProcessor, BackgroundProcessor,
@@ -47,7 +48,16 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
} }
async init(opts: ProcessorOptions<Track.Kind>) { async init(opts: ProcessorOptions<Track.Kind>) {
return this.processor.init(opts) await this.processor.init(opts)
// Embedded (token) mode: the constructor passed the raw imagePath,
// whose native load cannot carry the Authorization header. Swap it
// for a resolved blob object URL. No-op in regular mode.
if (this.opts.type === 'virtual') {
const imagePath = await resolveMediaUrl(this.opts.imagePath)
if (imagePath !== this.opts.imagePath) {
await this.processor.updateTransformerOptions({ imagePath })
}
}
} }
async restart(opts: ProcessorOptions<Track.Kind>) { async restart(opts: ProcessorOptions<Track.Kind>) {
@@ -59,6 +69,9 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
} }
async update(opts: ProcessorConfig): Promise<void> { async update(opts: ProcessorConfig): Promise<void> {
if (opts.type === 'virtual') {
opts = { ...opts, imagePath: await resolveMediaUrl(opts.imagePath) }
}
this.opts = opts this.opts = opts
const newProcessorType = const newProcessorType =
@@ -8,6 +8,7 @@ import {
ProcessorType, ProcessorType,
} from '../blur' } from '../blur'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useResolvedMediaUrls } from '@/features/files/hooks/useResolvedMediaUrls'
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives' import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip' import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { HStack, styled } from '@/styled-system/jsx' import { HStack, styled } from '@/styled-system/jsx'
@@ -277,6 +278,14 @@ export const EffectsConfiguration = ({
filesQ.data.count >= appConfig.background_image.max_count_by_user) ?? filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
false false
// Thumbnails are browser-native loads (CSS url()) which cannot carry
// the Authorization header in embedded (token) mode: resolve them. The
// processor configs keep the stable raw URLs - they are persisted in
// the user choices - and the processors resolve them internally.
const resolveMediaUrl = useResolvedMediaUrls(
(filesQ.data?.results ?? []).map((file) => file.url)
)
const getHandleSelectChangeFile = useCallback( const getHandleSelectChangeFile = useCallback(
(file: ApiFileItem) => { (file: ApiFileItem) => {
return async () => { return async () => {
@@ -754,7 +763,7 @@ export const EffectsConfiguration = ({
bgSize: 'cover', bgSize: 'cover',
})} })}
style={{ style={{
backgroundImage: `url(${option.file.url!})`, backgroundImage: `url(${resolveMediaUrl(option.file.url!)})`,
}} }}
data-attr={`toggle-virtual-${option.file.id}`} data-attr={`toggle-virtual-${option.file.id}`}
/> />
@@ -1,6 +1,17 @@
import { useRoomData } from './useRoomData' import {
useParticipantAttribute,
useRoomContext,
} from '@livekit/components-react'
import { ParticipantRole } from '@/features/rooms/api/ApiRoom'
export const useIsAdminOrOwner = () => { export const useIsAdminOrOwner = () => {
const apiRoomData = useRoomData() const room = useRoomContext()
return apiRoomData?.is_administrable const localParticipant = room.localParticipant
const role = useParticipantAttribute('room_role', {
participant: localParticipant,
})
return (
role !== undefined &&
['administrator', 'owner'].includes(role as ParticipantRole)
)
} }
@@ -8,7 +8,7 @@ import { useRemoteParticipants } from '@livekit/components-react'
import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions' import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf' import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin' import { getParticipantIsRoomAdminOrOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
import Source = Track.Source import Source = Track.Source
import { import {
NotificationType, NotificationType,
@@ -48,7 +48,7 @@ export const usePublishSourcesManager = () => {
}) })
const unprivilegedRemoteParticipants = remoteParticipants.filter( const unprivilegedRemoteParticipants = remoteParticipants.filter(
(participant) => !getParticipantIsRoomAdmin(participant) (participant) => !getParticipantIsRoomAdminOrOwner(participant)
) )
const currentSources = useMemo(() => { const currentSources = useMemo(() => {
@@ -13,8 +13,7 @@ import { useRoomContext } from '@livekit/components-react'
import { useRoomData } from './useRoomData' import { useRoomData } from './useRoomData'
/** /**
* Shape of the LiveKit room metadata blob pushed by the backend. * The subset of LiveKit's room metadata this hook actually uses.
* Matches RoomManagement.update_metadata {"configuration": room.configuration}
*/ */
type RoomLiveKitMetadata = { type RoomLiveKitMetadata = {
configuration?: RoomConfiguration configuration?: RoomConfiguration

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