mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-31 06:02:25 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5790bb1e47 | |||
| ca80540f7e | |||
| 80f3af0690 | |||
| 399617e247 | |||
| 89f8480e0b | |||
| d9bf6efa2a | |||
| 4cb7412194 | |||
| e8df597055 | |||
| 05dfcd11ca | |||
| b018832fcf | |||
| a269160f6f | |||
| c3afa84d9b | |||
| 8c6752a29f | |||
| 4c57432a03 | |||
| 3b7bfd999c | |||
| 7f386b2e2f | |||
| c55d8235fd | |||
| c7b23abd68 | |||
| 5a641a4366 | |||
| f043ad6f98 | |||
| 1141c1cecd |
@@ -12,6 +12,9 @@ and this project adheres to
|
||||
|
||||
- ✨(summary) report exception type in failure analytics
|
||||
- ✨(frontend) add configurable documentation menu item
|
||||
- ✨(frontend) allow promoting authenticated participants
|
||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -22,6 +25,9 @@ and this project adheres to
|
||||
- ⬆️(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
|
||||
- 🚚(backend) rename TelephonyService to SIPManagement
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -29,6 +35,9 @@ and this project adheres to
|
||||
- 🐛(summary) extend tasks auto retry logic
|
||||
- 🐛(summary) properly detect when failure webhook should be sent
|
||||
- 🐛(backend) preserve recording metadata when updating room access
|
||||
- 🐛(backend) allow any string as sub in the API serializer
|
||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||
- 🚸(frontend) show two initials in the Avatar when possible
|
||||
|
||||
## [1.24.0] - 2026-07-21
|
||||
|
||||
|
||||
@@ -389,6 +389,12 @@ build-k8s-cluster: \
|
||||
./bin/start-kind.sh
|
||||
.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
|
||||
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
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')
|
||||
|
||||
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
|
||||
|
||||
Executable
+182
@@ -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
|
||||
@@ -143,3 +143,24 @@ $ make start-tilt-keycloak
|
||||
```
|
||||
|
||||
Monitor Tilt’s 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).
|
||||
|
||||
@@ -8,3 +8,6 @@ class AnalyticsEvent(StrEnum):
|
||||
|
||||
# Rooms
|
||||
ROOM_CREATED = "room_created"
|
||||
|
||||
# Roomkit (meeting-room SIP devices)
|
||||
ROOMKIT_JOINED = "roomkit_joined"
|
||||
|
||||
@@ -16,6 +16,7 @@ class FeatureFlag:
|
||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||
"addons": "ADDONS_ENABLED",
|
||||
"application": "APPLICATION_ENABLED",
|
||||
"roomkit": "ROOMKIT_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -6,6 +6,11 @@ from django.http import Http404
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
from ..services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||
@@ -166,3 +171,30 @@ class CanMuteParticipant(permissions.BasePermission):
|
||||
|
||||
# LiveKit token scoped to this room
|
||||
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
|
||||
|
||||
@@ -183,13 +183,11 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=output["configuration"],
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
role=role,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
|
||||
output["is_administrable"] = is_admin_or_owner
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@@ -299,8 +297,8 @@ class RoomInviteSerializer(serializers.Serializer):
|
||||
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
|
||||
"""Base serializer for participant management operations."""
|
||||
|
||||
participant_identity = serializers.UUIDField(
|
||||
help_text="LiveKit participant identity (UUID format)"
|
||||
participant_identity = serializers.CharField(
|
||||
help_text="LiveKit participant identity (matching the user's sub format)"
|
||||
)
|
||||
|
||||
|
||||
@@ -312,6 +310,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"]
|
||||
|
||||
|
||||
|
||||
@@ -73,3 +73,15 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle the LiveKit SIP module requesting roomkit joins.
|
||||
|
||||
The roomkit endpoints are authenticated as a machine user, so all requests
|
||||
share a single throttle bucket. This is not a security measure against
|
||||
brute-force attacks but a guard against accidental hammering from a buggy
|
||||
SIP module.
|
||||
"""
|
||||
|
||||
scope = "roomkit_join"
|
||||
|
||||
@@ -88,6 +88,10 @@ from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.room_roles import (
|
||||
RoomRoleError,
|
||||
RoomRoleService,
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
@@ -639,6 +643,53 @@ class RoomViewSet(
|
||||
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(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
|
||||
@@ -429,7 +429,14 @@ class Room(Resource):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Generate a unique n-digit pin code for new rooms."""
|
||||
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
||||
|
||||
# Roomkit devices also join by PIN, so a PIN is needed as soon as
|
||||
# either integration is enabled.
|
||||
if (
|
||||
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
|
||||
and not self.pk
|
||||
and not self.pin_code
|
||||
):
|
||||
self.pin_code = self.generate_unique_pin_code(
|
||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Meet core roomkit API endpoints for meeting-room (SIP) device integration."""
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Authentication for the roomkit API of the Meet core app."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from core.recording.event.authentication import MachineUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServerToServerAuthentication(BaseAuthentication):
|
||||
"""Custom authentication class for roomkit server-to-server requests.
|
||||
|
||||
Validates the Authorization header against the roomkit server-to-server
|
||||
token. A valid PIN code is intentionally not enough to authenticate: the
|
||||
endpoints are restricted to the LiveKit SIP module's credentials.
|
||||
"""
|
||||
|
||||
AUTH_HEADER = "Authorization"
|
||||
TOKEN_TYPE = "Bearer" # noqa S105
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Validate the Bearer token from the Authorization header.
|
||||
|
||||
Returns a (MachineUser, token) pair on success, and raises
|
||||
AuthenticationFailed if the header is missing, malformed, or contains
|
||||
an invalid token.
|
||||
"""
|
||||
required_token = settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN
|
||||
if not required_token:
|
||||
raise AuthenticationFailed("Server-to-server token is not configured.")
|
||||
|
||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||
if not auth_header:
|
||||
logger.warning(
|
||||
"Roomkit authentication failed: missing Authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed("Authorization header is missing.")
|
||||
|
||||
# Validate token format and existence
|
||||
auth_parts = auth_header.split(" ")
|
||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||
raise AuthenticationFailed("Invalid authorization header.")
|
||||
|
||||
token = auth_parts[1]
|
||||
|
||||
# Use constant-time comparison to prevent timing attacks
|
||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
||||
logger.warning(
|
||||
"Roomkit authentication failed: invalid token (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed("Invalid server-to-server token.")
|
||||
|
||||
return MachineUser(username="roomkit"), token
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
return f"{self.TOKEN_TYPE} realm='Roomkit server to server'"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Serializers for the roomkit API of the Meet core app."""
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.api.serializers import BaseValidationOnlySerializer
|
||||
|
||||
|
||||
class RoomKitJoinSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate roomkit join requests from the LiveKit SIP module."""
|
||||
|
||||
pin_code = serializers.CharField(required=True)
|
||||
|
||||
def validate_pin_code(self, value):
|
||||
"""Ensure the PIN code matches the configured length."""
|
||||
if len(value) != settings.ROOM_TELEPHONY_PIN_LENGTH:
|
||||
raise serializers.ValidationError("PIN code length is invalid.")
|
||||
return value
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Roomkit API endpoints for meeting-room (SIP) device integration."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from rest_framework import decorators, viewsets
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import analytics, models
|
||||
from core.api import permissions, throttling
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.sip_management import SIPException, SIPManagement
|
||||
|
||||
from . import authentication, serializers
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class RoomKitViewSet(viewsets.ViewSet):
|
||||
"""Server-to-server API endpoints for the roomkit integration.
|
||||
|
||||
Groups all interactions between roomkit (SIP) devices and the backend,
|
||||
brokered by the LiveKit SIP module. All endpoints are authenticated
|
||||
with the roomkit server-to-server tokens.
|
||||
"""
|
||||
|
||||
authentication_classes = [authentication.ServerToServerAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="join",
|
||||
throttle_classes=[throttling.RoomKitJoinRateThrottle],
|
||||
)
|
||||
@FeatureFlag.require("roomkit")
|
||||
def join(self, request):
|
||||
"""Prepare a room for a meeting-room (SIP) device joining by PIN code.
|
||||
|
||||
Called by the LiveKit SIP module when a meeting-room device dials in
|
||||
with a PIN code before any WebRTC participant has joined. Resolves the
|
||||
room by PIN and creates its SIP dispatch rule, so the device can enter
|
||||
without waiting for a WebRTC user.
|
||||
|
||||
The webhook-based creation path is kept: both converge on the same rule
|
||||
through the shared SIPManagement.
|
||||
"""
|
||||
|
||||
serializer = serializers.RoomKitJoinSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
room = models.Room.objects.get(
|
||||
pin_code=serializer.validated_data["pin_code"]
|
||||
)
|
||||
except models.Room.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound("No room found for this PIN code.") from e
|
||||
|
||||
try:
|
||||
created = SIPManagement().ensure_dispatch_rule(room)
|
||||
except SIPException as e:
|
||||
raise drf_exceptions.APIException("Could not create dispatch rule.") from e
|
||||
|
||||
analytics.capture(
|
||||
request.user,
|
||||
analytics.AnalyticsEvent.ROOMKIT_JOINED,
|
||||
{
|
||||
"room_id": str(room.pk),
|
||||
"dispatch_rule_created": created,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Roomkit join requested: room_id=%s, dispatch_rule_created=%s",
|
||||
room.id,
|
||||
created,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success"},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
@@ -28,7 +28,7 @@ from .room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from .telephony import TelephonyException, TelephonyService
|
||||
from .sip_management import SIPException, SIPManagement
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -107,7 +107,7 @@ class LiveKitEventsService:
|
||||
)
|
||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||
self.lobby_service = LobbyService()
|
||||
self.telephony_service = TelephonyService()
|
||||
self.sip_management = SIPManagement()
|
||||
self.recording_events = RecordingEventsService()
|
||||
|
||||
self._filter_regex = None
|
||||
@@ -245,12 +245,12 @@ class LiveKitEventsService:
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED:
|
||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||
try:
|
||||
self.telephony_service.create_dispatch_rule(room)
|
||||
except TelephonyException as e:
|
||||
self.sip_management.ensure_dispatch_rule(room)
|
||||
except SIPException as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to create telephony dispatch rule for room {room_id}"
|
||||
f"Failed to create sip dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
def _handle_room_finished(self, data):
|
||||
@@ -265,12 +265,12 @@ class LiveKitEventsService:
|
||||
)
|
||||
raise ActionFailedError("Failed to process room finished event") from e
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED:
|
||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||
try:
|
||||
self.telephony_service.delete_dispatch_rule(room_id)
|
||||
except TelephonyException as e:
|
||||
self.sip_management.delete_dispatch_rule(room_id)
|
||||
except SIPException as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to delete telephony dispatch rule for room {room_id}"
|
||||
f"Failed to delete sip dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
try:
|
||||
|
||||
@@ -104,21 +104,30 @@ class LobbyService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def can_bypass_lobby(room, user) -> bool:
|
||||
def can_bypass_lobby(room, user, role) -> bool:
|
||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||
|
||||
A user can bypass the lobby if:
|
||||
1. The room is public (open to everyone)
|
||||
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.
|
||||
This function only checks the current state and should be called each time
|
||||
a participant requests entry to ensure consistent access control, even for
|
||||
participants who have already begun waiting.
|
||||
"""
|
||||
return room.is_public or (
|
||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and user.is_authenticated
|
||||
return (
|
||||
room.is_public
|
||||
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(
|
||||
@@ -144,8 +153,9 @@ class LobbyService:
|
||||
participant = self._get_participant(room.id, participant_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 = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
@@ -162,8 +172,8 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -183,8 +193,8 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -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
|
||||
+38
-10
@@ -1,9 +1,9 @@
|
||||
"""Telephony service for managing SIP dispatch rules for room access."""
|
||||
"""SIP management service for managing SIP dispatch rules for room access."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import TwirpError
|
||||
from livekit.api import TwirpError, TwirpErrorCode
|
||||
from livekit.protocol.sip import (
|
||||
CreateSIPDispatchRuleRequest,
|
||||
DeleteSIPDispatchRuleRequest,
|
||||
@@ -17,12 +17,16 @@ from core import utils
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class TelephonyException(Exception):
|
||||
"""Exception raised when telephony operations fail."""
|
||||
class SIPException(Exception):
|
||||
"""Exception raised when SIP operations fail."""
|
||||
|
||||
|
||||
class TelephonyService:
|
||||
"""Service for managing participant access through the telephony system (SIP)."""
|
||||
class DispatchRuleConflictError(SIPException):
|
||||
"""Raised when a dispatch rule already exists for the same routing criteria."""
|
||||
|
||||
|
||||
class SIPManagement:
|
||||
"""Service for managing SIP access through the telephony or roomkit system (SIP)."""
|
||||
|
||||
def _rule_name(self, room_id):
|
||||
"""Generate the rule name for a room based on its ID."""
|
||||
@@ -32,7 +36,7 @@ class TelephonyService:
|
||||
async def create_dispatch_rule(self, room):
|
||||
"""Create a SIP inbound dispatch rule for direct room routing.
|
||||
|
||||
Configures telephony to route incoming SIP calls directly to the specified room
|
||||
Configures livekit-sip to route incoming SIP calls directly to the specified room
|
||||
using the room's ID and PIN code for authentication.
|
||||
"""
|
||||
|
||||
@@ -51,10 +55,12 @@ class TelephonyService:
|
||||
try:
|
||||
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
||||
except TwirpError as e:
|
||||
if e.code == TwirpErrorCode.ALREADY_EXISTS:
|
||||
raise DispatchRuleConflictError("Dispatch rule already exists") from e
|
||||
logger.exception(
|
||||
"Unexpected error creating dispatch rule for room %s", room.id
|
||||
)
|
||||
raise TelephonyException("Could not create dispatch rule") from e
|
||||
raise SIPException("Could not create dispatch rule") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -79,7 +85,7 @@ class TelephonyService:
|
||||
)
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to list dispatch rules for room %s", room_id)
|
||||
raise TelephonyException("Could not list dispatch rules") from e
|
||||
raise SIPException("Could not list dispatch rules") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@@ -94,6 +100,28 @@ class TelephonyService:
|
||||
if existing_rule.name == rule_name
|
||||
]
|
||||
|
||||
@async_to_sync
|
||||
async def has_dispatch_rule(self, room_id):
|
||||
"""Check whether at least one dispatch rule exists for a specific room."""
|
||||
return bool(await self._list_dispatch_rules_ids(room_id))
|
||||
|
||||
def ensure_dispatch_rule(self, room):
|
||||
"""Create the SIP dispatch rule for a room if it does not already exist.
|
||||
|
||||
Returns:
|
||||
bool: True if a rule was created, False if it already existed.
|
||||
"""
|
||||
|
||||
if self.has_dispatch_rule(room.pk):
|
||||
return False
|
||||
|
||||
try:
|
||||
self.create_dispatch_rule(room)
|
||||
except DispatchRuleConflictError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@async_to_sync
|
||||
async def delete_dispatch_rule(self, room_id):
|
||||
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
||||
@@ -118,7 +146,7 @@ class TelephonyService:
|
||||
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to delete dispatch rules for room %s", room_id)
|
||||
raise TelephonyException("Could not delete dispatch rules") from e
|
||||
raise SIPException("Could not delete dispatch rules") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the roomkit API of the Meet core app."""
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Test the roomkit join server-to-server API endpoint.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from ...factories import RoomFactory
|
||||
from ...services.sip_management import SIPException
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sip_management():
|
||||
"""Mock the SIPManagement used by the roomkit viewset."""
|
||||
with mock.patch("core.roomkit.viewsets.SIPManagement") as mock_service_class:
|
||||
yield mock_service_class.return_value
|
||||
|
||||
|
||||
def test_join_anonymous(settings, mock_sip_management, client):
|
||||
"""Requests without an Authorization header should be rejected."""
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
|
||||
response = client.post("/api/v1.0/roomkit/join/", {"pin_code": room.pin_code})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Authorization header is missing."}
|
||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_join_malformed_authorization_header(settings, mock_sip_management, client):
|
||||
"""Requests with a malformed Authorization header should be rejected."""
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Invalid authorization header."}
|
||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_join_wrong_bearer(settings, mock_sip_management, client):
|
||||
"""Requests with an incorrect bearer token should be rejected."""
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Invalid server-to-server token."}
|
||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_join_token_not_configured(settings, mock_sip_management, client):
|
||||
"""Requests should be rejected when no server-to-server token is configured."""
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = None
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_join_roomkit_disabled(settings, mock_sip_management, client):
|
||||
"""The endpoint should not be exposed when the roomkit integration is disabled."""
|
||||
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_join_missing_pin(settings, mock_sip_management, client):
|
||||
"""Requests without a PIN code should be rejected."""
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"pin_code": ["This field is required."]}
|
||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_join_blank_pin(settings, mock_sip_management, client):
|
||||
"""Requests with a blank PIN code should be rejected."""
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": ""},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"pin_code": ["This field may not be blank."]}
|
||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_join_wrong_pin_length(settings, mock_sip_management, client):
|
||||
"""Requests with a PIN code of unexpected length should be rejected."""
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": "123"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"pin_code": ["PIN code length is invalid."]}
|
||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_join_unknown_pin(settings, mock_sip_management, client):
|
||||
"""Requests with a PIN matching no room should return 404 and create no rule."""
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
RoomFactory(pin_code="1234567890")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": "0987654321"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No room found for this PIN code."}
|
||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_join_success(settings, mock_sip_management, client):
|
||||
"""Requests with a valid PIN should create the dispatch rule."""
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
mock_sip_management.ensure_dispatch_rule.return_value = True
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
mock_sip_management.ensure_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
def test_join_dispatch_rule_already_exists(settings, mock_sip_management, client):
|
||||
"""Requests should succeed when the dispatch rule already exists (idempotency)."""
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
mock_sip_management.ensure_dispatch_rule.return_value = False
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
mock_sip_management.ensure_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
def test_join_tracks_analytics_event(settings, mock_sip_management, client):
|
||||
"""Successful joins should be tracked with an analytics event."""
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
mock_sip_management.ensure_dispatch_rule.return_value = True
|
||||
|
||||
with mock.patch("core.roomkit.viewsets.analytics.capture") as mock_capture:
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_capture.assert_called_once()
|
||||
_user, event, properties = mock_capture.call_args[0]
|
||||
assert str(event) == "roomkit_joined"
|
||||
assert properties == {
|
||||
"room_id": str(room.pk),
|
||||
"dispatch_rule_created": True,
|
||||
}
|
||||
|
||||
|
||||
def test_join_sip_failure(settings, mock_sip_management, client):
|
||||
"""Requests should fail with a server error when the sip management service fails."""
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
mock_sip_management.ensure_dispatch_rule.side_effect = SIPException(
|
||||
"Could not create dispatch rule"
|
||||
)
|
||||
|
||||
with mock.patch("core.roomkit.viewsets.analytics.capture") as mock_capture:
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
raise_request_exception=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
mock_sip_management.ensure_dispatch_rule.assert_called_once_with(room)
|
||||
mock_capture.assert_not_called()
|
||||
@@ -80,7 +80,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
||||
room = RoomFactory()
|
||||
|
||||
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})
|
||||
response = client.post(
|
||||
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
||||
other_room = RoomFactory()
|
||||
|
||||
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})
|
||||
response = client.post(
|
||||
@@ -146,7 +146,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||
|
||||
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})
|
||||
response = client.post(
|
||||
@@ -293,7 +293,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
||||
)
|
||||
# Token identity matches the admin user so LiveKitTokenAuthentication
|
||||
# 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})
|
||||
response = client.post(
|
||||
@@ -323,7 +323,7 @@ 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
|
||||
# honored when established via session, never via a LiveKit
|
||||
# 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})
|
||||
response = client.post(
|
||||
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
# 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})
|
||||
response = client.post(
|
||||
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
||||
room = RoomFactory()
|
||||
|
||||
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})
|
||||
response = client.post(
|
||||
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
||||
)
|
||||
|
||||
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})
|
||||
response = client.post(
|
||||
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
||||
)
|
||||
|
||||
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})
|
||||
response = client.post(
|
||||
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
)
|
||||
|
||||
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})
|
||||
response = client.post(
|
||||
@@ -719,13 +719,13 @@ def test_update_participant_invalid_payload():
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid"}
|
||||
payload = {"participant_identity": ["test"]}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
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():
|
||||
@@ -918,7 +918,7 @@ def test_remove_participant_invalid_payload():
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid"}
|
||||
payload = {"participant_identity": ["invalid-uuid"]}
|
||||
|
||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
@@ -12,7 +12,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...models import RoleChoices, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -31,7 +31,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -51,7 +50,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"configuration": {},
|
||||
"access_level": "trusted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -70,7 +68,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -87,7 +84,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -104,7 +100,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -214,7 +209,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -261,7 +255,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -278,7 +271,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -313,7 +306,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -330,7 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
is_admin_or_owner=False,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -355,7 +347,6 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -401,7 +392,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -418,7 +408,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
role=str(RoleChoices.MEMBER),
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -493,7 +483,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
assert content_dict == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": True,
|
||||
"configuration": {},
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
@@ -511,6 +500,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
is_admin_or_owner=True,
|
||||
role=str(user_access.role),
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -21,7 +21,10 @@ from core.services.livekit_events import (
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.room_management import RoomManagementException
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.services.sip_management import (
|
||||
SIPException,
|
||||
SIPManagement,
|
||||
)
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -59,7 +62,7 @@ def test_initialization(
|
||||
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
||||
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
||||
assert isinstance(service.lobby_service, LobbyService)
|
||||
assert isinstance(service.telephony_service, TelephonyService)
|
||||
assert isinstance(service.sip_management, SIPManagement)
|
||||
assert isinstance(service.recording_events, RecordingEventsService)
|
||||
|
||||
|
||||
@@ -469,11 +472,11 @@ def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
|
||||
"""Should clear lobby cache and delete SIP dispatch rule when room finishes."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
mock_room_name = uuid.uuid4()
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -486,12 +489,31 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_deletes_dispatch_rule_when_only_roomkit_enabled(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should delete dispatch rule when only roomkit is enabled when room finishes."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
mock_room_name = uuid.uuid4()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(mock_room_name)
|
||||
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
|
||||
mock_clear_cache.assert_called_once_with(mock_room_name)
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_skips_telephony_when_disabled(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
mock_room_name = uuid.uuid4()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(mock_room_name)
|
||||
@@ -505,7 +527,7 @@ def test_handle_room_finished_skips_telephony_when_disabled(
|
||||
@mock.patch.object(
|
||||
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
||||
)
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
@@ -528,9 +550,9 @@ def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(
|
||||
TelephonyService,
|
||||
SIPManagement,
|
||||
"delete_dispatch_rule",
|
||||
side_effect=TelephonyException("Test error"),
|
||||
side_effect=SIPException("Test error"),
|
||||
)
|
||||
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
@@ -541,7 +563,7 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
expected_error = (
|
||||
"Failed to delete telephony dispatch rule for room "
|
||||
"Failed to delete sip dispatch rule for room "
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
)
|
||||
|
||||
@@ -562,11 +584,11 @@ def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||
mock_create_dispatch_rule, service, settings
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should create telephony dispatch rule when room starts successfully."""
|
||||
"""Should ensure the SIP dispatch rule exists when room starts successfully."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -574,22 +596,75 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_create_dispatch_rule.assert_called_once_with(room)
|
||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||
mock_create_dispatch_rule, service, settings
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled(
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
|
||||
"""Should ensure the dispatch rule exists when only roomkit is enabled during room start."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_create_dispatch_rule.assert_not_called()
|
||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule", return_value=False)
|
||||
def test_handle_room_started_ignores_existing_dispatch_rule(
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should proceed silently when the dispatch rule already exists when room starts."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
# ensure_dispatch_rule reports the rule as pre-existing: nothing to raise
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
SIPManagement,
|
||||
"ensure_dispatch_rule",
|
||||
side_effect=SIPException("Test error"),
|
||||
)
|
||||
def test_handle_room_started_raises_error_when_dispatch_rule_creation_fails(
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should raise ActionFailedError when ensuring the dispatch rule fails when room starts."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
expected_error = f"Failed to create sip dispatch rule for room {room.id}"
|
||||
|
||||
with pytest.raises(ActionFailedError, match=expected_error):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should skip ensuring the SIP dispatch rule when telephony is disabled during room start."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||
|
||||
@@ -9,13 +9,14 @@ import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.models import RoleChoices, RoomAccessLevel
|
||||
from core.services.lobby import (
|
||||
LobbyParticipant,
|
||||
LobbyParticipantNotFound,
|
||||
@@ -188,17 +189,17 @@ def test_prepare_response_new_cookie(lobby_service, participant_id):
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
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
|
||||
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):
|
||||
@@ -208,7 +209,7 @@ def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||
# Authenticated user
|
||||
user = mock.Mock()
|
||||
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):
|
||||
@@ -218,21 +219,34 @@ def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
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):
|
||||
"""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)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
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
|
||||
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")
|
||||
@@ -241,7 +255,7 @@ def test_request_entry_public_room(
|
||||
):
|
||||
"""Test requesting entry to a public room."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
@@ -266,8 +280,8 @@ def test_request_entry_public_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -279,8 +293,7 @@ def test_request_entry_trusted_room(
|
||||
):
|
||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user.is_authenticated = True
|
||||
request.user = UserFactory()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
@@ -305,8 +318,8 @@ def test_request_entry_trusted_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -319,6 +332,7 @@ def test_request_entry_new_participant(
|
||||
"""Test requesting entry for a new participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -348,6 +362,7 @@ def test_request_entry_waiting_participant(
|
||||
"""Test requesting entry for a waiting participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -374,7 +389,7 @@ def test_request_entry_accepted_participant(
|
||||
):
|
||||
"""Test requesting entry for an accepted participant."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -400,8 +415,48 @@ def test_request_entry_accepted_participant(
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
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()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
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_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
+162
-35
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Test telephony service.
|
||||
Test SIP mamagement service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212
|
||||
@@ -20,7 +20,11 @@ from livekit.protocol.sip import (
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.services.sip_management import (
|
||||
DispatchRuleConflictError,
|
||||
SIPException,
|
||||
SIPManagement,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -35,9 +39,9 @@ def create_mock_livekit_client():
|
||||
|
||||
def test_rule_name():
|
||||
"""Test rule name generation."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
rule_name = telephony_service._rule_name(room.id)
|
||||
rule_name = sip_management._rule_name(room.id)
|
||||
|
||||
assert rule_name == f"SIP_{str(room.id)}"
|
||||
|
||||
@@ -45,14 +49,14 @@ def test_rule_name():
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_create_dispatch_rule_success(mock_client_factory):
|
||||
"""Test successful dispatch rule creation."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
telephony_service.create_dispatch_rule(room)
|
||||
sip_management.create_dispatch_rule(room)
|
||||
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
||||
@@ -67,7 +71,7 @@ def test_create_dispatch_rule_success(mock_client_factory):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||
"""Test dispatch rule creation when API fails."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
@@ -76,8 +80,8 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
|
||||
telephony_service.create_dispatch_rule(room)
|
||||
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
||||
sip_management.create_dispatch_rule(room)
|
||||
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
@@ -86,7 +90,7 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||
"""Test successful listing of dispatch rule IDs."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_rules = [
|
||||
@@ -111,7 +115,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "rule-1" in result
|
||||
@@ -127,7 +131,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when no rules exist."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
@@ -136,7 +140,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert result == []
|
||||
mock_api.aclose.assert_called_once()
|
||||
@@ -145,7 +149,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when no rules match the room."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_rules = [
|
||||
@@ -163,7 +167,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert result == []
|
||||
mock_api.aclose.assert_called_once()
|
||||
@@ -172,7 +176,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when API fails."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
@@ -181,34 +185,34 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
|
||||
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
with pytest.raises(SIPException, match="Could not list dispatch rules"):
|
||||
async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting dispatch rules when no rules exist."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = []
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
result = sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is False
|
||||
mock_list_rules.assert_called_once_with(room.id)
|
||||
mock_client_factory.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting a single dispatch rule."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1"]
|
||||
@@ -216,7 +220,7 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
result = sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is True
|
||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||
@@ -226,11 +230,11 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting multiple dispatch rules."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||
@@ -238,7 +242,7 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
result = sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is True
|
||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
|
||||
@@ -253,11 +257,11 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting multiple dispatch rules when one deletion fails."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||
@@ -277,18 +281,18 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||
telephony_service.delete_dispatch_rule(room.id)
|
||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
||||
sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting dispatch rules when API fails immediately."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1"]
|
||||
@@ -298,8 +302,131 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||
telephony_service.delete_dispatch_rule(room.id)
|
||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
||||
sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_create_dispatch_rule_conflict_raises_dedicated_error(mock_client_factory):
|
||||
"""Test that a LiveKit conflict error raises DispatchRuleConflictError."""
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(
|
||||
msg=(
|
||||
"Dispatch rule for the same trunk, inbound number, number, and "
|
||||
"PIN combination already exists in dispatch rule"
|
||||
),
|
||||
code="already_exists",
|
||||
status=409,
|
||||
)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(DispatchRuleConflictError):
|
||||
sip_management.create_dispatch_rule(room)
|
||||
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_ensure_dispatch_rule_creates_when_missing(mock_client_factory):
|
||||
"""Test that ensure_dispatch_rule creates the rule when none exists."""
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
||||
)
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
created = sip_management.ensure_dispatch_rule(room)
|
||||
|
||||
assert created is True
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
||||
|
||||
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
|
||||
assert create_request.name == f"SIP_{str(room.id)}"
|
||||
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
|
||||
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_ensure_dispatch_rule_skips_when_existing(mock_client_factory):
|
||||
"""Test that ensure_dispatch_rule is idempotent when the rule already exists."""
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
existing_rule = SIPDispatchRuleInfo(
|
||||
sip_dispatch_rule_id="rule-1", name=f"SIP_{str(room.id)}"
|
||||
)
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=[existing_rule])
|
||||
)
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
created = sip_management.ensure_dispatch_rule(room)
|
||||
|
||||
assert created is False
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_ensure_dispatch_rule_returns_false_on_conflict(mock_client_factory):
|
||||
"""Test that ensure_dispatch_rule tolerates a concurrent rule creation.
|
||||
|
||||
If the rule is created by a concurrent caller (e.g. the LiveKit webhook)
|
||||
between the existence check and the creation, LiveKit rejects the
|
||||
duplicate and ensure_dispatch_rule reports the rule as already existing.
|
||||
"""
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
||||
)
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(
|
||||
msg=(
|
||||
"Dispatch rule for the same trunk, inbound number, number, and "
|
||||
"PIN combination already exists in dispatch rule"
|
||||
),
|
||||
code="already_exists",
|
||||
status=409,
|
||||
)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
created = sip_management.ensure_dispatch_rule(room)
|
||||
|
||||
assert created is False
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_ensure_dispatch_rule_raises_on_other_failures(mock_client_factory):
|
||||
"""Test that ensure_dispatch_rule propagates unexpected LiveKit failures."""
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
||||
)
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
||||
sip_management.ensure_dispatch_rule(room)
|
||||
@@ -184,12 +184,13 @@ def test_models_rooms_is_public_property():
|
||||
|
||||
|
||||
@mock.patch.object(Room, "generate_unique_pin_code")
|
||||
def test_telephony_disabled_skips_pin_generation(
|
||||
def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
||||
mock_generate_unique_pin_code, settings
|
||||
):
|
||||
"""Telephony disabled should not generate pin codes."""
|
||||
"""Telephony and roomkit both disabled should not generate pin codes."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
@@ -197,6 +198,18 @@ def test_telephony_disabled_skips_pin_generation(
|
||||
assert room.pin_code is None
|
||||
|
||||
|
||||
def test_roomkit_enabled_generates_pin_code(settings):
|
||||
"""Roomkit enabled alone should generate pin codes, even without telephony."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
assert room.pin_code is not None
|
||||
assert len(room.pin_code) == settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
|
||||
|
||||
def test_default_and_custom_pin_length(settings):
|
||||
"""Pin codes should be created with correct configured length."""
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
|
||||
from core.addons import viewsets as addons_viewsets
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.external_api import viewsets as external_viewsets
|
||||
from core.roomkit import viewsets as roomkit_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
@@ -19,6 +20,11 @@ router.register("files", viewsets.FileViewSet, basename="files")
|
||||
router.register(
|
||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||
)
|
||||
router.register(
|
||||
"roomkit",
|
||||
roomkit_viewsets.RoomKitViewSet,
|
||||
basename="roomkit",
|
||||
)
|
||||
router.register(
|
||||
"addons/sessions",
|
||||
addons_viewsets.SessionViewSet,
|
||||
|
||||
@@ -65,7 +65,7 @@ def generate_token(
|
||||
username: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
sources: Optional[List[str]] = None,
|
||||
is_admin_or_owner: bool = False,
|
||||
role: Optional[str] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
@@ -79,7 +79,7 @@ def generate_token(
|
||||
If none, a value will be generated
|
||||
sources: (Optional[List[str]]): List of media sources the user can publish
|
||||
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;
|
||||
used as identity when user.is_anonymous.
|
||||
|
||||
@@ -87,6 +87,7 @@ def generate_token(
|
||||
str: The LiveKit JWT access token.
|
||||
"""
|
||||
|
||||
is_admin_or_owner = role in ("owner", "administrator")
|
||||
if is_admin_or_owner:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
@@ -127,7 +128,11 @@ def generate_token(
|
||||
.with_identity(identity)
|
||||
.with_name(display_name)
|
||||
.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",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -138,7 +143,7 @@ def generate_livekit_config(
|
||||
room_id: str,
|
||||
user,
|
||||
username: str,
|
||||
is_admin_or_owner: bool,
|
||||
role: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
configuration: Optional[dict] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
@@ -149,7 +154,7 @@ def generate_livekit_config(
|
||||
room_id: Room identifier
|
||||
user: User instance requesting access
|
||||
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.
|
||||
configuration (Optional[dict]): Room configuration dict that can override default settings.
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
@@ -172,7 +177,7 @@ def generate_livekit_config(
|
||||
username=username,
|
||||
color=color,
|
||||
sources=sources,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
role=role,
|
||||
participant_id=participant_id,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -349,6 +349,11 @@ class Base(Configuration):
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"roomkit_join": values.Value(
|
||||
default="300/minute",
|
||||
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||
@@ -881,6 +886,21 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Roomkit (meeting-room SIP devices) integration
|
||||
ROOMKIT_ENABLED = values.BooleanValue(
|
||||
False,
|
||||
environ_name="ROOMKIT_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Server-to-server API token allowing the LiveKit SIP module to call the
|
||||
# roomkit endpoints (e.g. join a room on behalf of a meeting-room device
|
||||
# dialing in before any WebRTC participant).
|
||||
ROOMKIT_SERVER_TO_SERVER_API_TOKEN = SecretFileValue(
|
||||
None,
|
||||
environ_name="ROOMKIT_SERVER_TO_SERVER_API_TOKEN",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Subtitles settings
|
||||
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
||||
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
||||
|
||||
@@ -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> & {
|
||||
name?: string
|
||||
bgColor?: string
|
||||
@@ -35,7 +44,7 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
|
||||
export const Avatar = React.memo(
|
||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0) ?? ''
|
||||
const initials = getInitials(name)
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: bgColor, ...style }}
|
||||
@@ -52,11 +61,11 @@ export const Avatar = React.memo(
|
||||
y="50"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="52"
|
||||
fontSize={initials.length > 1 ? 48 : 52}
|
||||
fontWeight="500"
|
||||
fill="currentColor"
|
||||
>
|
||||
{initial}
|
||||
{initials.toUpperCase()}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -168,6 +168,31 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
}, [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(() => {
|
||||
const removeParticipantNotifications = (participant: Participant) => {
|
||||
toastQueue.visibleToasts.forEach((toast) => {
|
||||
|
||||
@@ -14,4 +14,5 @@ export const NotificationDuration = {
|
||||
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||
RECORDING_REQUESTED: ToastDuration.LONG,
|
||||
ROLE_CHANGED: ToastDuration.LONG,
|
||||
} as const
|
||||
|
||||
@@ -17,4 +17,5 @@ export enum NotificationType {
|
||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||
RecordingSaving = 'recordingSaving',
|
||||
PermissionsRemoved = 'permissionsRemoved',
|
||||
RoleChanged = 'roleChanged',
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ToastRecordingSaving } from './ToastRecordingSaving'
|
||||
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
||||
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -70,6 +71,9 @@ const renderToast = (
|
||||
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
|
||||
)
|
||||
|
||||
case NotificationType.RoleChanged:
|
||||
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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 { Avatar } from '@/components/Avatar'
|
||||
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 { isLocal } from '@/utils/livekit'
|
||||
import {
|
||||
@@ -20,7 +24,9 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||
import { PinBadge } from './PinBadge'
|
||||
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
type MicIndicatorProps = {
|
||||
participant: Participant
|
||||
@@ -106,52 +112,26 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
||||
width: 'full',
|
||||
})}
|
||||
>
|
||||
<HStack>
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
<HStack flex="1" minW="0">
|
||||
<div className={css({ position: 'relative', flexShrink: 0 })}>
|
||||
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
||||
<PinBadge participant={participant} />
|
||||
<UnauthenticatedBadge participant={participant} />
|
||||
</div>
|
||||
<VStack gap={0} alignItems="start">
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
})}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<VStack gap={0} alignItems="start" minW="0" flex="1">
|
||||
<ParticipantName
|
||||
displayedName={name}
|
||||
isLocal={isLocal(participant)}
|
||||
/>
|
||||
<Text variant="xsNote">
|
||||
{getParticipantIsRoomOwner(participant) && t('participants.host')}
|
||||
{getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
|
||||
{getParticipantIsRoomMember(participant) &&
|
||||
t('participants.member')}
|
||||
</Text>
|
||||
{getParticipantIsRoomAdmin(participant) && (
|
||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<HStack flexShrink={0}>
|
||||
<MicIndicator participant={participant} />
|
||||
<ParticipantMenuButton participant={participant} />
|
||||
</HStack>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Text } from '@/primitives/Text'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
|
||||
@@ -11,6 +10,7 @@ import { isLocal } from '@/utils/livekit'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
const ActionButton = ({
|
||||
participant,
|
||||
@@ -42,9 +42,7 @@ type HandRaisedListItemProps = {
|
||||
}
|
||||
|
||||
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
return (
|
||||
<HStack
|
||||
role="listitem"
|
||||
@@ -56,42 +54,15 @@ export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
||||
width: 'full',
|
||||
})}
|
||||
>
|
||||
<HStack>
|
||||
<HStack flex="1" minW="0" overflow="hidden">
|
||||
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
})}
|
||||
>
|
||||
<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>
|
||||
<ParticipantName displayedName={name} isLocal={isLocal(participant)} />
|
||||
</HStack>
|
||||
<HStack flexShrink={0}>
|
||||
<AdminOrOwnerOnly>
|
||||
<ActionButton participant={participant} name={name} />
|
||||
</AdminOrOwnerOnly>
|
||||
</HStack>
|
||||
<AdminOrOwnerOnly>
|
||||
<ActionButton participant={participant} name={name} />
|
||||
</AdminOrOwnerOnly>
|
||||
</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 { css } from '@/styled-system/css'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WaitingParticipant } from '../api/listWaitingParticipants'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
export const WaitingParticipantRow = ({
|
||||
participant,
|
||||
@@ -26,42 +27,11 @@ export const WaitingParticipantRow = ({
|
||||
width: 'full',
|
||||
})}
|
||||
>
|
||||
<HStack
|
||||
className={css({
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
})}
|
||||
>
|
||||
<HStack flex="1" minW="0">
|
||||
<Avatar name={participant.username} bgColor={participant.color} />
|
||||
<Text
|
||||
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>
|
||||
<ParticipantName displayedName={participant.username} isLocal={false} />
|
||||
</HStack>
|
||||
<HStack
|
||||
gap="0.25rem"
|
||||
className={css({
|
||||
flexShrink: '0',
|
||||
})}
|
||||
>
|
||||
<HStack gap="0.25rem" flexShrink={0}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
|
||||
@@ -1,24 +1,46 @@
|
||||
import { Menu as RACMenu } from 'react-aria-components'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { PinMenuItem } from './items/PinMenuItem'
|
||||
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 = ({
|
||||
participant,
|
||||
}: {
|
||||
participant: Participant
|
||||
}) => {
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const canModerateParticipant = !participant.isLocal && isAdminOrOwner
|
||||
const isLocalUserAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
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 (
|
||||
<RACMenu
|
||||
style={{
|
||||
minWidth: '75px',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { RiMore2Fill } from '@remixicon/react'
|
||||
import { ParticipantMenu } from './ParticipantMenu'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -11,8 +10,6 @@ export const ParticipantMenuButton = ({
|
||||
participant: Participant
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
if (!isAdminOrOwner) return null
|
||||
return (
|
||||
<Menu>
|
||||
<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 { HStack } from '@/styled-system/jsx'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
@@ -6,23 +6,30 @@ import { MenuItem } from 'react-aria-components'
|
||||
import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const RemoveMenuItem = ({
|
||||
participant,
|
||||
}: {
|
||||
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>
|
||||
)
|
||||
type RemoveMenuItemProps = {
|
||||
identity: string
|
||||
displayedName?: string
|
||||
}
|
||||
|
||||
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
|
||||
configuration?: RoomConfiguration
|
||||
}
|
||||
|
||||
export type ParticipantRole = 'member' | 'administrator' | 'owner'
|
||||
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useRoomData } from '../livekit/hooks/useRoomData'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export const useRemoveParticipant = () => {
|
||||
const data = useRoomData()
|
||||
|
||||
const removeParticipant = async (participant: Participant) => {
|
||||
const removeParticipant = async (identity: string) => {
|
||||
if (!data?.id) {
|
||||
throw new Error('Room id is not available')
|
||||
}
|
||||
@@ -13,7 +12,7 @@ export const useRemoveParticipant = () => {
|
||||
return fetchApi(`rooms/${data.id}/remove-participant/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_identity: participant.identity,
|
||||
participant_identity: identity,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ export const Join = ({
|
||||
refetch: refetchRoom,
|
||||
} = useQuery({
|
||||
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
|
||||
retry: false,
|
||||
enabled: false,
|
||||
@@ -339,7 +339,7 @@ export const Join = ({
|
||||
|
||||
const { status, startWaiting } = useLobby({
|
||||
roomId,
|
||||
username,
|
||||
username: username || user?.full_name || 'anonymous',
|
||||
onAccepted: handleAccepted,
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'wouter'
|
||||
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
|
||||
import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
||||
import { useEffect } from 'react'
|
||||
import { closeSidePanel } from '@/stores/layout'
|
||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||
|
||||
export const Admin = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||
@@ -23,6 +26,14 @@ export const Admin = () => {
|
||||
|
||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdminOrOwner) {
|
||||
closeSidePanel()
|
||||
}
|
||||
}, [isAdminOrOwner])
|
||||
|
||||
const { data: readOnlyData } = useQuery({
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { closeSidePanel, layoutStore } from '@/stores/layout'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { text } from '@/primitives/Text'
|
||||
@@ -199,10 +199,7 @@ export const SidePanel = () => {
|
||||
ref={asideRef}
|
||||
title={title}
|
||||
ariaLabel={t('ariaLabel', { title })}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
}}
|
||||
onClose={closeSidePanel}
|
||||
closeButtonTooltip={t('closeButton', {
|
||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||
})}
|
||||
|
||||
@@ -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 = () => {
|
||||
const apiRoomData = useRoomData()
|
||||
return apiRoomData?.is_administrable
|
||||
const room = useRoomContext()
|
||||
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 { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
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 {
|
||||
NotificationType,
|
||||
@@ -48,7 +48,7 @@ export const usePublishSourcesManager = () => {
|
||||
})
|
||||
|
||||
const unprivilegedRemoteParticipants = remoteParticipants.filter(
|
||||
(participant) => !getParticipantIsRoomAdmin(participant)
|
||||
(participant) => !getParticipantIsRoomAdminOrOwner(participant)
|
||||
)
|
||||
|
||||
const currentSources = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
|
||||
export const getParticipantIsAuthenticated = (
|
||||
participant: Participant
|
||||
): boolean => {
|
||||
const isAuthenticated = participant.attributes?.is_authenticated
|
||||
return isAuthenticated == 'true'
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
|
||||
export const getParticipantIsRoomAdmin = (
|
||||
participant: Participant
|
||||
): boolean => {
|
||||
const attributes = participant.attributes
|
||||
|
||||
if (!attributes) {
|
||||
return false
|
||||
}
|
||||
return attributes?.room_admin === 'true'
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { ParticipantRole } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
const participantHasRoomRole = (
|
||||
participant: Participant,
|
||||
roles: ParticipantRole[]
|
||||
): boolean => {
|
||||
const role = participant.attributes?.room_role
|
||||
return role !== undefined && roles.includes(role as ParticipantRole)
|
||||
}
|
||||
|
||||
export const getParticipantIsRoomAdmin = (participant: Participant): boolean =>
|
||||
participantHasRoomRole(participant, ['administrator'])
|
||||
|
||||
export const getParticipantIsRoomOwner = (participant: Participant): boolean =>
|
||||
participantHasRoomRole(participant, ['owner'])
|
||||
|
||||
export const getParticipantIsRoomMember = (participant: Participant): boolean =>
|
||||
participantHasRoomRole(participant, ['member'])
|
||||
|
||||
export const getParticipantIsRoomAdminOrOwner = (
|
||||
participant: Participant
|
||||
): boolean => participantHasRoomRole(participant, ['administrator', 'owner'])
|
||||
@@ -54,5 +54,13 @@
|
||||
"default": "Deine Aufzeichnung wird gespeichert! Du erhältst eine E-Mail, sobald sie bereit ist."
|
||||
}
|
||||
},
|
||||
"roleChanged": {
|
||||
"body": "Deine Rolle wurde geändert. Du bist jetzt {{role}}.",
|
||||
"roles": {
|
||||
"owner": "Host",
|
||||
"administrator": "Co-host",
|
||||
"member": "Mitglied"
|
||||
}
|
||||
},
|
||||
"openMenu": "Menü öffnen"
|
||||
}
|
||||
|
||||
@@ -583,11 +583,16 @@
|
||||
"you": "Du",
|
||||
"unknown": "Unbekannte Person",
|
||||
"host": "Host",
|
||||
"cohost": "Co-host",
|
||||
"member": "Mitglied",
|
||||
"contributors": "Teilnehmende",
|
||||
"collapsable": {
|
||||
"open": "{{name}}-Liste öffnen",
|
||||
"close": "{{name}}-Liste schließen"
|
||||
},
|
||||
"unauthenticated": {
|
||||
"badge": "Nicht authentifizierter Benutzer"
|
||||
},
|
||||
"muteYourself": "Mikrofon ausschalten",
|
||||
"muteParticipant": "{{name}}’s Mikrofon ausschalten",
|
||||
"muteParticipantAlert": {
|
||||
@@ -627,6 +632,14 @@
|
||||
"pin": {
|
||||
"label": "Anheften",
|
||||
"ariaLabel": "Hefte {{name}} an"
|
||||
},
|
||||
"promote": {
|
||||
"label": "Zum Co-Host machen",
|
||||
"ariaLabel": "{{name}} zum Co-Host machen"
|
||||
},
|
||||
"demote": {
|
||||
"label": "Co-Host-Rolle entfernen",
|
||||
"ariaLabel": "Die Co-Host-Rolle von {{name}} entfernen"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
|
||||
@@ -54,5 +54,13 @@
|
||||
"default": "Your recording is being saved! We’ll send a notification to your email as soon as it’s ready."
|
||||
}
|
||||
},
|
||||
"roleChanged": {
|
||||
"body": "Your role has changed. You are now {{role}}.",
|
||||
"roles": {
|
||||
"owner": "Host",
|
||||
"administrator": "Co-host",
|
||||
"member": "Member"
|
||||
}
|
||||
},
|
||||
"openMenu": "Open menu"
|
||||
}
|
||||
|
||||
@@ -582,11 +582,16 @@
|
||||
"you": "You",
|
||||
"unknown": "Unknown participant",
|
||||
"host": "Host",
|
||||
"cohost": "Co-host",
|
||||
"member": "Member",
|
||||
"contributors": "Contributors",
|
||||
"collapsable": {
|
||||
"open": "Open {{name}} list",
|
||||
"close": "Close {{name}} list"
|
||||
},
|
||||
"unauthenticated": {
|
||||
"badge": "User is not authenticated"
|
||||
},
|
||||
"muteYourself": "Close your mic",
|
||||
"muteParticipant": "Close the mic of {{name}}",
|
||||
"muteParticipantAlert": {
|
||||
@@ -626,6 +631,14 @@
|
||||
"pin": {
|
||||
"label": "Pin",
|
||||
"ariaLabel": "Pin {{name}}"
|
||||
},
|
||||
"promote": {
|
||||
"label": "Make co-host",
|
||||
"ariaLabel": "Make {{name}} a co-host"
|
||||
},
|
||||
"demote": {
|
||||
"label": "Remove co-host role",
|
||||
"ariaLabel": "Remove {{name}}'s co-host role"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
|
||||
@@ -54,5 +54,13 @@
|
||||
"default": "Nous finalisons votre enregistrement ! Vous recevrez un e-mail dès qu’il sera prêt."
|
||||
}
|
||||
},
|
||||
"roleChanged": {
|
||||
"body": "Votre rôle a changé, vous êtes maitenant {{role}}.",
|
||||
"roles": {
|
||||
"owner": "Organisateur",
|
||||
"administrator": "Co-organisateur",
|
||||
"member": "Membre"
|
||||
}
|
||||
},
|
||||
"openMenu": "Ouvrir le menu"
|
||||
}
|
||||
|
||||
@@ -583,10 +583,15 @@
|
||||
"unknown": "Participant inconnu",
|
||||
"contributors": "Contributeurs",
|
||||
"host": "Organisateur de la réunion",
|
||||
"cohost": "Co-organisateur",
|
||||
"member": "Membre",
|
||||
"collapsable": {
|
||||
"open": "Ouvrir la liste {{name}}",
|
||||
"close": "Fermer la liste {{name}}"
|
||||
},
|
||||
"unauthenticated": {
|
||||
"badge": "Utilisateur non authentifié"
|
||||
},
|
||||
"muteYourself": "Couper votre micro",
|
||||
"muteParticipant": "Couper le micro de {{name}}",
|
||||
"muteParticipantAlert": {
|
||||
@@ -626,6 +631,14 @@
|
||||
"pin": {
|
||||
"label": "Épingler",
|
||||
"ariaLabel": "Épingler {{name}}"
|
||||
},
|
||||
"promote": {
|
||||
"label": "Nommer co-organisateur",
|
||||
"ariaLabel": "Nommer {{name}} co-organisateur"
|
||||
},
|
||||
"demote": {
|
||||
"label": "Retirer le rôle de co-organisateur",
|
||||
"ariaLabel": "Retirer le rôle de co-organisateur à {{name}}"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
|
||||
@@ -54,5 +54,13 @@
|
||||
"default": "We zijn uw opname aan het voltooien! U ontvangt een e-mail zodra deze klaar is."
|
||||
}
|
||||
},
|
||||
"roleChanged": {
|
||||
"body": "Je rol is gewijzigd. Je bent nu {{role}}.",
|
||||
"roles": {
|
||||
"owner": "Host",
|
||||
"administrator": "Co-host",
|
||||
"member": "Lid"
|
||||
}
|
||||
},
|
||||
"openMenu": "Menu openen"
|
||||
}
|
||||
|
||||
@@ -582,11 +582,16 @@
|
||||
"you": "U",
|
||||
"unknown": "Onbekende deelnemer",
|
||||
"host": "Host",
|
||||
"cohost": "Co-host",
|
||||
"member": "Lid",
|
||||
"contributors": "Deelnemers",
|
||||
"collapsable": {
|
||||
"open": "Open {{name}} lijst",
|
||||
"close": "Sluit {{name}} lijst"
|
||||
},
|
||||
"unauthenticated": {
|
||||
"badge": "Niet authentifizierter Benutzer"
|
||||
},
|
||||
"muteYourself": "Uw microfoon dempen",
|
||||
"muteParticipant": "Demp de microfoon van {{name}}",
|
||||
"muteParticipantAlert": {
|
||||
@@ -626,6 +631,14 @@
|
||||
"pin": {
|
||||
"label": "Pinnen",
|
||||
"ariaLabel": "Maak {{name}} vast"
|
||||
},
|
||||
"promote": {
|
||||
"label": "Co-host maken",
|
||||
"ariaLabel": "{{name}} co-host maken"
|
||||
},
|
||||
"demote": {
|
||||
"label": "Co-hostrol verwijderen",
|
||||
"ariaLabel": "De co-hostrol van {{name}} verwijderen"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
|
||||
@@ -16,24 +16,19 @@ export type VisualOnlyTooltipProps = {
|
||||
tooltip: string
|
||||
ariaLabel?: string
|
||||
tooltipPosition?: 'top' | 'bottom'
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that displays a tooltip visually only (not announced by screen readers).
|
||||
*
|
||||
* This is necessary because TooltipTrigger from react-aria-components automatically adds
|
||||
* aria-describedby on the button, which links the tooltip for accessibility.
|
||||
* Even with aria-hidden="true" on the tooltip, screen readers still announce its content → duplication.
|
||||
* This CSS wrapper avoids TooltipTrigger → no automatic aria-describedby → no duplication.
|
||||
*
|
||||
* Uses a portal to avoid being clipped by parent containers with overflow: hidden.
|
||||
*/
|
||||
export const VisualOnlyTooltip = ({
|
||||
type VisualOnlyTooltipInnerProps = Omit<VisualOnlyTooltipProps, 'disabled'>
|
||||
|
||||
const VisualOnlyTooltipInner = ({
|
||||
children,
|
||||
tooltip,
|
||||
ariaLabel,
|
||||
className,
|
||||
tooltipPosition = 'top',
|
||||
}: VisualOnlyTooltipProps) => {
|
||||
}: VisualOnlyTooltipInnerProps) => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const { getContainer } = useUNSAFE_PortalContext()
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
@@ -135,6 +130,7 @@ export const VisualOnlyTooltip = ({
|
||||
onMouseLeave={hideTooltip}
|
||||
onFocus={showTooltip}
|
||||
onBlur={hideTooltip}
|
||||
className={className}
|
||||
>
|
||||
{wrappedChild}
|
||||
</div>
|
||||
@@ -183,3 +179,25 @@ export const VisualOnlyTooltip = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that displays a tooltip visually only (not announced by screen readers).
|
||||
*
|
||||
* This is necessary because TooltipTrigger from react-aria-components automatically adds
|
||||
* aria-describedby on the button, which links the tooltip for accessibility.
|
||||
* Even with aria-hidden="true" on the tooltip, screen readers still announce its content → duplication.
|
||||
* This CSS wrapper avoids TooltipTrigger → no automatic aria-describedby → no duplication.
|
||||
*
|
||||
* Uses a portal to avoid being clipped by parent containers with overflow: hidden.
|
||||
*/
|
||||
export const VisualOnlyTooltip = ({
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: VisualOnlyTooltipProps) => {
|
||||
if (disabled) {
|
||||
return children
|
||||
}
|
||||
|
||||
return <VisualOnlyTooltipInner {...props}>{children}</VisualOnlyTooltipInner>
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ export const clearTextAreaValue = () => {
|
||||
}
|
||||
|
||||
export function resetChatStore() {
|
||||
console.count('resetChatStore')
|
||||
Object.assign(chatStore, {
|
||||
...initialState,
|
||||
rows: [],
|
||||
|
||||
@@ -34,3 +34,8 @@ export const setPinnedTrack = (trackRef: TrackReferenceOrPlaceholder): void => {
|
||||
export const clearPinnedTrack = (): void => {
|
||||
layoutStore.pinnedTrackRef = undefined
|
||||
}
|
||||
|
||||
export const closeSidePanel = (): void => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user