mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a2c4ce0fb | |||
| 6f30eac723 | |||
| b61c0a3bc0 | |||
| 72f40ecf48 | |||
| 10fd18caf8 | |||
| 10e8bca5a4 |
@@ -12,7 +12,6 @@ and this project adheres to
|
||||
|
||||
- ✨(summary) report exception type in failure analytics
|
||||
- ✨(frontend) add configurable documentation menu item
|
||||
- ✨(frontend) add connection test feature
|
||||
|
||||
## Fixed
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Connection test API endpoint."""
|
||||
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework.decorators import api_view, throttle_classes
|
||||
from rest_framework.response import Response
|
||||
|
||||
from core.api.throttling import (
|
||||
ConnectionTestAnonRateThrottle,
|
||||
ConnectionTestUserRateThrottle,
|
||||
)
|
||||
from core.utils import generate_token
|
||||
|
||||
CONNECTION_TEST_USERNAME = "Test connexion"
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@throttle_classes([ConnectionTestUserRateThrottle, ConnectionTestAnonRateThrottle])
|
||||
def get_connection_test_config(request):
|
||||
"""Return a short-lived LiveKit token for an ephemeral connection test room."""
|
||||
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid4()}"
|
||||
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
|
||||
|
||||
return Response(
|
||||
{
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": room,
|
||||
"token": generate_token(
|
||||
room=room,
|
||||
user=request.user,
|
||||
username=CONNECTION_TEST_USERNAME,
|
||||
is_admin_or_owner=False,
|
||||
ttl=timedelta(seconds=expires_in),
|
||||
),
|
||||
"expires_in": expires_in,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -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,12 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=output["configuration"],
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
role=role,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
|
||||
output["is_administrable"] = is_admin_or_owner
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@@ -312,6 +311,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,15 +73,3 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle authenticated users requesting connection test tokens."""
|
||||
|
||||
scope = "connection_test"
|
||||
|
||||
|
||||
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous users requesting connection test tokens."""
|
||||
|
||||
scope = "connection_test"
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -218,21 +218,9 @@ class LiveKitEventsService:
|
||||
|
||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||
|
||||
@staticmethod
|
||||
def _is_connection_test_room(room_name: str) -> bool:
|
||||
"""Return True for ephemeral rooms created by the connection test endpoint."""
|
||||
return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
|
||||
|
||||
def _handle_room_started(self, data):
|
||||
"""Handle 'room_started' event."""
|
||||
|
||||
if self._is_connection_test_room(data.room.name):
|
||||
logger.info(
|
||||
"Ignoring room_started event for connection test room '%s'.",
|
||||
data.room.name,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
room_id = uuid.UUID(data.room.name)
|
||||
except ValueError as e:
|
||||
@@ -258,13 +246,6 @@ class LiveKitEventsService:
|
||||
def _handle_room_finished(self, data):
|
||||
"""Handle 'room_finished' event."""
|
||||
|
||||
if self._is_connection_test_room(data.room.name):
|
||||
logger.info(
|
||||
"Ignoring room_finished event for connection test room '%s'.",
|
||||
data.room.name,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
room_id = uuid.UUID(data.room.name)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -162,7 +162,6 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
)
|
||||
return participant, livekit_config
|
||||
@@ -183,7 +182,6 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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,8 +271,9 @@ 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,
|
||||
is_authenticated=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -313,7 +307,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,8 +323,9 @@ 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,
|
||||
is_authenticated=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -355,7 +349,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 +394,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,8 +410,9 @@ 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,
|
||||
is_authenticated=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -493,7 +486,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 +503,7 @@ 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,
|
||||
is_authenticated=True,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -6,8 +6,6 @@ Test LiveKitEvents service.
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
@@ -552,23 +550,6 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||
mock_clear_cache.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_ignores_connection_test_room(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should ignore room_finished events for connection test rooms."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
|
||||
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_delete_dispatch_rule.assert_not_called()
|
||||
mock_clear_cache.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -619,15 +600,6 @@ def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||
def test_handle_room_started_ignores_connection_test_room(service, settings):
|
||||
"""Should ignore room_started events for connection test rooms."""
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
def test_handle_room_started_raises_error_for_nonexistent_room(service):
|
||||
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
|
||||
mock_data = mock.MagicMock()
|
||||
|
||||
@@ -266,7 +266,6 @@ def test_request_entry_public_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
)
|
||||
|
||||
@@ -305,7 +304,6 @@ def test_request_entry_trusted_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
)
|
||||
|
||||
@@ -400,7 +398,6 @@ def test_request_entry_accepted_participant(
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Test connection test API endpoint."""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.api.connection_test import CONNECTION_TEST_USERNAME
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(
|
||||
CONNECTION_TEST_TOKEN_TTL_SECONDS=600,
|
||||
CONNECTION_TEST_ROOM_PREFIX="connection-test-",
|
||||
)
|
||||
def test_api_connection_test_returns_ephemeral_livekit_config():
|
||||
"""Each request gets a dedicated room and a short-lived token."""
|
||||
client = APIClient()
|
||||
response_a = client.get("/api/v1.0/connection-test/")
|
||||
response_b = client.get("/api/v1.0/connection-test/")
|
||||
|
||||
assert response_a.status_code == 200
|
||||
assert response_b.status_code == 200
|
||||
|
||||
data_a = response_a.json()
|
||||
data_b = response_b.json()
|
||||
|
||||
room_a = data_a["livekit"]["room"]
|
||||
room_b = data_b["livekit"]["room"]
|
||||
|
||||
assert room_a.startswith("connection-test-")
|
||||
assert room_b.startswith("connection-test-")
|
||||
uuid.UUID(room_a.removeprefix("connection-test-"))
|
||||
uuid.UUID(room_b.removeprefix("connection-test-"))
|
||||
assert room_a != room_b
|
||||
assert data_a["livekit"]["url"]
|
||||
assert data_a["livekit"]["token"]
|
||||
assert data_a["livekit"]["expires_in"] == 600
|
||||
assert data_a["livekit"]["token"] != data_b["livekit"]["token"]
|
||||
|
||||
|
||||
@override_settings(CONNECTION_TEST_TOKEN_TTL_SECONDS=300)
|
||||
def test_api_connection_test_token_is_short_lived_for_user(settings):
|
||||
"""Connection test tokens expire quickly for users."""
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/connection-test/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
config = response.json()["livekit"]
|
||||
payload = jwt.decode(
|
||||
config["token"],
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
|
||||
assert config["expires_in"] == 300
|
||||
assert payload["video"]["room"] == config["room"]
|
||||
assert payload["name"] == CONNECTION_TEST_USERNAME
|
||||
assert payload["video"]["roomAdmin"] is False
|
||||
assert payload["exp"] - payload["nbf"] == 300
|
||||
@@ -8,7 +8,6 @@ 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.api.connection_test import get_connection_test_config
|
||||
from core.external_api import viewsets as external_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
@@ -47,11 +46,6 @@ urlpatterns = [
|
||||
*router.urls,
|
||||
*oidc_urls,
|
||||
path("config/", get_frontend_configuration, name="config"),
|
||||
path(
|
||||
"connection-test/",
|
||||
get_connection_test_config,
|
||||
name="connection_test",
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
+12
-11
@@ -12,7 +12,6 @@ import mimetypes
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
from datetime import timedelta
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
@@ -67,9 +66,9 @@ 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,
|
||||
ttl: Optional[timedelta] = None,
|
||||
is_authenticated: Optional[bool] = False,
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
@@ -82,15 +81,16 @@ 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.
|
||||
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
|
||||
is_authenticated (Optional[bool]): Is user authentified.
|
||||
|
||||
Returns:
|
||||
str: The LiveKit JWT access token.
|
||||
"""
|
||||
|
||||
is_admin_or_owner = role in ("owner", "administrator")
|
||||
if is_admin_or_owner:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
@@ -131,11 +131,9 @@ 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 is_authenticated else 'false'}
|
||||
)
|
||||
)
|
||||
if ttl is not None:
|
||||
token = token.with_ttl(ttl)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
@@ -144,10 +142,11 @@ 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,
|
||||
is_authenticated: Optional[bool] = False,
|
||||
) -> dict:
|
||||
"""Generate LiveKit configuration for room access.
|
||||
|
||||
@@ -155,11 +154,12 @@ 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;
|
||||
used as identity when user.is_anonymous.
|
||||
is_authenticated: Optional[bool]: Is user authentified.
|
||||
|
||||
Returns:
|
||||
dict: LiveKit configuration with URL, room and access token
|
||||
@@ -178,8 +178,9 @@ def generate_livekit_config(
|
||||
username=username,
|
||||
color=color,
|
||||
sources=sources,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
role=role,
|
||||
participant_id=participant_id,
|
||||
is_authenticated=is_authenticated,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -349,11 +349,6 @@ class Base(Configuration):
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"connection_test": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="CONNECTION_TEST_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||
@@ -660,16 +655,6 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
default=False,
|
||||
)
|
||||
CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue(
|
||||
600,
|
||||
environ_name="CONNECTION_TEST_TOKEN_TTL_SECONDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
CONNECTION_TEST_ROOM_PREFIX = values.Value(
|
||||
"connection-test-",
|
||||
environ_name="CONNECTION_TEST_ROOM_PREFIX",
|
||||
environ_prefix=None,
|
||||
)
|
||||
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export type ConnectionTestTokenResponse = {
|
||||
livekit: {
|
||||
url: string
|
||||
room: string
|
||||
token: string
|
||||
expires_in: number
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchConnectionTestToken = () =>
|
||||
fetchApi<ConnectionTestTokenResponse>('/connection-test/')
|
||||
@@ -1,245 +0,0 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
CheckStatus,
|
||||
ConnectionCheck,
|
||||
createLocalAudioTrack,
|
||||
createLocalVideoTrack,
|
||||
getBrowser,
|
||||
type CheckInfo,
|
||||
type LocalVideoTrack,
|
||||
} from 'livekit-client'
|
||||
import { fetchConnectionTestToken } from '../api/fetchConnectionTestToken'
|
||||
import {
|
||||
createInitialSteps,
|
||||
type ConnectionTestLog,
|
||||
type ConnectionTestStepId,
|
||||
type ConnectionTestStepResult,
|
||||
type ConnectionTestStepStatus,
|
||||
} from '../types'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
|
||||
const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [
|
||||
'websocket',
|
||||
'webrtc',
|
||||
'turn',
|
||||
'reconnect',
|
||||
'publishAudio',
|
||||
'publishVideo',
|
||||
]
|
||||
|
||||
const CHECK_STATUS_TO_STEP: Record<CheckStatus, ConnectionTestStepStatus> = {
|
||||
[CheckStatus.IDLE]: 'pending',
|
||||
[CheckStatus.RUNNING]: 'running',
|
||||
[CheckStatus.SUCCESS]: 'success',
|
||||
[CheckStatus.FAILED]: 'failed',
|
||||
[CheckStatus.SKIPPED]: 'skipped',
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
|
||||
error instanceof Error ? error.message : fallback
|
||||
|
||||
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
|
||||
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
|
||||
summary: info.description,
|
||||
logs: info.logs,
|
||||
})
|
||||
|
||||
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
|
||||
const grouped: Record<MediaDeviceKind, string[]> = {
|
||||
audioinput: [],
|
||||
audiooutput: [],
|
||||
videoinput: [],
|
||||
}
|
||||
for (const device of devices) {
|
||||
grouped[device.kind].push(device.label || device.deviceId)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
export const useConnectionTestRunner = () => {
|
||||
const [steps, setSteps] = useState(createInitialSteps)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [videoTrack, setVideoTrack] = useState<LocalVideoTrack | null>(null)
|
||||
const videoTrackRef = useRef<LocalVideoTrack | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const updateStep = (
|
||||
id: ConnectionTestStepId,
|
||||
patch: Partial<ConnectionTestStepResult>
|
||||
) => {
|
||||
setSteps((current) =>
|
||||
current.map((step) => (step.id === id ? { ...step, ...patch } : step))
|
||||
)
|
||||
}
|
||||
|
||||
const stopVideoTrack = () => {
|
||||
videoTrackRef.current?.stop()
|
||||
videoTrackRef.current = null
|
||||
setVideoTrack(null)
|
||||
}
|
||||
|
||||
const skipSteps = (
|
||||
ids: ConnectionTestStepId[],
|
||||
summary: string,
|
||||
logs?: ConnectionTestLog[]
|
||||
) => {
|
||||
for (const id of ids) {
|
||||
updateStep(id, { status: 'skipped', summary, logs })
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true on success, false on failure, null if aborted. */
|
||||
const runStep = async (
|
||||
id: ConnectionTestStepId,
|
||||
signal: AbortSignal,
|
||||
fn: () => Promise<Partial<ConnectionTestStepResult>>
|
||||
): Promise<boolean | null> => {
|
||||
if (signal.aborted) return null
|
||||
|
||||
updateStep(id, { status: 'running', summary: undefined, logs: undefined })
|
||||
|
||||
try {
|
||||
const result = await fn()
|
||||
if (signal.aborted) return null
|
||||
// `result.status` overrides when set (LiveKit checks map their own status)
|
||||
updateStep(id, { status: 'success', ...result })
|
||||
return true
|
||||
} catch (error) {
|
||||
if (signal.aborted) return null
|
||||
updateStep(id, {
|
||||
status: 'failed',
|
||||
summary: getErrorMessage(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async () => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const { signal } = controller
|
||||
|
||||
setIsRunning(true)
|
||||
setSteps(createInitialSteps())
|
||||
stopVideoTrack()
|
||||
|
||||
try {
|
||||
await runStep('browser', signal, async () => {
|
||||
const browser = getBrowser()
|
||||
if (!browser) throw new Error('Browser not detected')
|
||||
return {
|
||||
summary: `${browser.name} ${browser.version}`,
|
||||
data: {
|
||||
name: browser.name,
|
||||
version: browser.version,
|
||||
os: browser.os,
|
||||
osVersion: browser.osVersion,
|
||||
},
|
||||
}
|
||||
})
|
||||
if (signal.aborted) return
|
||||
|
||||
const microphoneOk = await runStep('microphone', signal, async () => {
|
||||
const track = await createLocalAudioTrack()
|
||||
const label =
|
||||
track.mediaStreamTrack.label ||
|
||||
track.mediaStreamTrack.getSettings().deviceId ||
|
||||
''
|
||||
track.stop()
|
||||
return { summary: label, data: { label } }
|
||||
})
|
||||
if (signal.aborted) return
|
||||
if (!microphoneOk) openPermissionsDialog('audioinput')
|
||||
|
||||
const cameraOk = await runStep('camera', signal, async () => {
|
||||
const track = await createLocalVideoTrack()
|
||||
videoTrackRef.current = track
|
||||
setVideoTrack(track)
|
||||
const settings = track.mediaStreamTrack.getSettings()
|
||||
const label = track.mediaStreamTrack.label || ''
|
||||
return {
|
||||
summary: label,
|
||||
data: {
|
||||
label,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
},
|
||||
}
|
||||
})
|
||||
if (signal.aborted) return
|
||||
if (!cameraOk) openPermissionsDialog('videoinput')
|
||||
|
||||
await runStep('devices', signal, async () => {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
return {
|
||||
summary: String(devices.length),
|
||||
data: groupDevicesByKind(devices),
|
||||
}
|
||||
})
|
||||
if (signal.aborted) return
|
||||
|
||||
let checker: ConnectionCheck
|
||||
try {
|
||||
const { livekit } = await fetchConnectionTestToken()
|
||||
checker = new ConnectionCheck(livekit.url, livekit.token)
|
||||
} catch (error) {
|
||||
skipSteps(
|
||||
LIVEKIT_STEP_IDS,
|
||||
getErrorMessage(error, 'Failed to fetch test token')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await runStep('websocket', signal, async () =>
|
||||
fromCheckInfo(await checker.checkWebsocket())
|
||||
)
|
||||
await runStep('webrtc', signal, async () =>
|
||||
fromCheckInfo(await checker.checkWebRTC())
|
||||
)
|
||||
await runStep('turn', signal, async () =>
|
||||
fromCheckInfo(await checker.checkTURN())
|
||||
)
|
||||
await runStep('reconnect', signal, async () =>
|
||||
fromCheckInfo(await checker.checkReconnect())
|
||||
)
|
||||
|
||||
if (!microphoneOk) {
|
||||
skipSteps(['publishAudio'], 'Microphone permission required')
|
||||
} else {
|
||||
await runStep('publishAudio', signal, async () =>
|
||||
fromCheckInfo(await checker.checkPublishAudio())
|
||||
)
|
||||
}
|
||||
|
||||
if (!cameraOk) {
|
||||
skipSteps(['publishVideo'], 'Camera permission required')
|
||||
} else {
|
||||
stopVideoTrack()
|
||||
await runStep('publishVideo', signal, async () =>
|
||||
fromCheckInfo(await checker.checkPublishVideo())
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
stopVideoTrack()
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
abortRef.current?.abort()
|
||||
stopVideoTrack()
|
||||
setSteps(createInitialSteps())
|
||||
setIsRunning(false)
|
||||
}
|
||||
|
||||
return {
|
||||
steps,
|
||||
isRunning,
|
||||
videoTrack,
|
||||
runTest,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { Box, Button, Text, Ul } from '@/primitives'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { Center, HStack, VStack } from '@/styled-system/jsx'
|
||||
import { Permissions } from '@/features/rooms/components/Permissions'
|
||||
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
|
||||
import type { ConnectionTestStepId, ConnectionTestStepResult } from '../types'
|
||||
import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport'
|
||||
|
||||
const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video'
|
||||
|
||||
const TestStepItem = ({ step }: { step: ConnectionTestStepResult }) => {
|
||||
const { t } = useTranslation('connectionTest')
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const hasLogs = Boolean(step.logs?.length)
|
||||
const statusLabel = t(`status.${step.status}`)
|
||||
const stepLabel = t(`steps.${step.id as ConnectionTestStepId}`)
|
||||
|
||||
const statusVariant =
|
||||
step.status === 'failed'
|
||||
? 'warning'
|
||||
: step.status === 'success'
|
||||
? 'body'
|
||||
: 'smNote'
|
||||
|
||||
return (
|
||||
<VStack gap="0.25rem" alignItems="stretch" width="100%">
|
||||
<HStack
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-start"
|
||||
width="100%"
|
||||
>
|
||||
<Text variant="bodyXsMedium">{stepLabel}</Text>
|
||||
{step.status === 'running' ? (
|
||||
<Spinner size={20} />
|
||||
) : (
|
||||
<Text variant={statusVariant}>{statusLabel}</Text>
|
||||
)}
|
||||
</HStack>
|
||||
{step.summary && (
|
||||
<Text variant="smNote" margin={false}>
|
||||
{step.summary}
|
||||
</Text>
|
||||
)}
|
||||
{hasLogs && step.status !== 'pending' && step.status !== 'running' && (
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
size="sm"
|
||||
onPress={() => setShowDetails((open) => !open)}
|
||||
>
|
||||
{t('details')}
|
||||
</Button>
|
||||
)}
|
||||
{showDetails && step.logs && (
|
||||
<Ul>
|
||||
{step.logs.map((log, index) => (
|
||||
<li key={index}>
|
||||
<Text variant="xsNote" as="span">
|
||||
{log.message}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</Ul>
|
||||
)}
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
const ConnectionTest = () => {
|
||||
const { t } = useTranslation('connectionTest')
|
||||
const { steps, isRunning, videoTrack, runTest } = useConnectionTestRunner()
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
const hasStarted = steps.some((step) => step.status !== 'pending')
|
||||
const hasFailed = steps.some((step) => step.status === 'failed')
|
||||
const isPublishVideoRunning = steps.some(
|
||||
(step) => step.id === 'publishVideo' && step.status === 'running'
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const element = videoRef.current
|
||||
if (!element || !videoTrack) return
|
||||
|
||||
videoTrack.attach(element)
|
||||
return () => {
|
||||
videoTrack.detach(element)
|
||||
}
|
||||
}, [videoTrack])
|
||||
|
||||
// LiveKit appends a bare <video> to document.body during publishVideo.
|
||||
// Keep it in the DOM (so the frame check still works) but hide it visually.
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle(
|
||||
HIDE_LIVEKIT_VIDEO_CLASS,
|
||||
isPublishVideoRunning
|
||||
)
|
||||
return () => {
|
||||
document.body.classList.remove(HIDE_LIVEKIT_VIDEO_CLASS)
|
||||
}
|
||||
}, [isPublishVideoRunning])
|
||||
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<Permissions />
|
||||
<CenteredContent title={t('title')} withBackButton>
|
||||
<Center>
|
||||
<VStack gap="1.5rem" maxWidth="36rem" width="100%">
|
||||
<Text as="p" variant="paragraph" centered last>
|
||||
{t('intro')}
|
||||
</Text>
|
||||
|
||||
<Button variant="primary" onPress={runTest} isDisabled={isRunning}>
|
||||
{hasStarted ? t('reset') : t('runTest')}
|
||||
</Button>
|
||||
|
||||
{videoTrack && (
|
||||
<Center>
|
||||
<video ref={videoRef} autoPlay playsInline muted />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{hasStarted && (
|
||||
<Box variant="light" width="100%">
|
||||
<VStack gap="1rem" alignItems="stretch">
|
||||
{steps.map((step) => (
|
||||
<TestStepItem key={step.id} step={step} />
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasFailed && !isRunning && (
|
||||
<Text variant="smNote" centered>
|
||||
{t('help.firewall')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{hasStarted && !isRunning && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onPress={() => downloadConnectionTestReport(steps)}
|
||||
>
|
||||
{t('downloadReport')}
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
</Center>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConnectionTest
|
||||
@@ -1,47 +0,0 @@
|
||||
export type ConnectionTestStepId =
|
||||
| 'browser'
|
||||
| 'microphone'
|
||||
| 'camera'
|
||||
| 'devices'
|
||||
| 'websocket'
|
||||
| 'webrtc'
|
||||
| 'turn'
|
||||
| 'reconnect'
|
||||
| 'publishAudio'
|
||||
| 'publishVideo'
|
||||
|
||||
export type ConnectionTestStepStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'skipped'
|
||||
|
||||
export type ConnectionTestLog = {
|
||||
level: 'info' | 'warning' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ConnectionTestStepResult = {
|
||||
id: ConnectionTestStepId
|
||||
status: ConnectionTestStepStatus
|
||||
summary?: string
|
||||
logs?: ConnectionTestLog[]
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const CONNECTION_TEST_STEP_IDS: ConnectionTestStepId[] = [
|
||||
'browser',
|
||||
'microphone',
|
||||
'camera',
|
||||
'devices',
|
||||
'websocket',
|
||||
'webrtc',
|
||||
'turn',
|
||||
'reconnect',
|
||||
'publishAudio',
|
||||
'publishVideo',
|
||||
]
|
||||
|
||||
export const createInitialSteps = (): ConnectionTestStepResult[] =>
|
||||
CONNECTION_TEST_STEP_IDS.map((id) => ({ id, status: 'pending' }))
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ConnectionTestStepResult } from '../types'
|
||||
|
||||
export type ConnectionTestReport = {
|
||||
generatedAt: string
|
||||
userAgent: string
|
||||
steps: Record<
|
||||
string,
|
||||
{
|
||||
status: ConnectionTestStepResult['status']
|
||||
summary?: string
|
||||
logs?: ConnectionTestStepResult['logs']
|
||||
data?: ConnectionTestStepResult['data']
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export const buildConnectionTestReport = (
|
||||
steps: ConnectionTestStepResult[]
|
||||
): ConnectionTestReport => ({
|
||||
generatedAt: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
steps: Object.fromEntries(
|
||||
steps.map(({ id, status, summary, logs, data }) => [
|
||||
id,
|
||||
{
|
||||
status,
|
||||
...(summary !== undefined ? { summary } : {}),
|
||||
...(logs?.length ? { logs } : {}),
|
||||
...(data !== undefined ? { data } : {}),
|
||||
},
|
||||
])
|
||||
),
|
||||
})
|
||||
|
||||
export const downloadConnectionTestReport = (
|
||||
steps: ConnectionTestStepResult[]
|
||||
) => {
|
||||
const report = buildConnectionTestReport(steps)
|
||||
const timestamp = report.generatedAt.slice(0, 19).replace(/:/g, '-')
|
||||
const blob = new Blob([JSON.stringify(report, null, 2)], {
|
||||
type: 'application/json',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `connection-test-${timestamp}.json`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ 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 { getParticipantIsRoomOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
|
||||
import { type LocalParticipant, type Participant, Track } from 'livekit-client'
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import {
|
||||
@@ -146,7 +146,7 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
{getParticipantIsRoomAdmin(participant) && (
|
||||
{getParticipantIsRoomOwner(participant) && (
|
||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
@@ -28,3 +28,6 @@ export type ApiRoom = {
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: RoomConfiguration
|
||||
}
|
||||
|
||||
export type ParticipantRole = 'member' | 'administrator' | 'owner'
|
||||
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
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 (
|
||||
participant: Participant,
|
||||
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: participant.identity,
|
||||
role: role,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to update participant's role ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return { updateParticipantRole }
|
||||
}
|
||||
@@ -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,20 @@
|
||||
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 getParticipantIsRoomAdminOrOwner = (
|
||||
participant: Participant
|
||||
): boolean => participantHasRoomRole(participant, ['administrator', 'owner'])
|
||||
@@ -266,16 +266,6 @@ export const Footer = () => {
|
||||
{t('links.accessibility')}
|
||||
</Link>
|
||||
</StyledLi>
|
||||
<StyledLi divider>
|
||||
<Link
|
||||
underline={false}
|
||||
footer="minor"
|
||||
to="/test-connection"
|
||||
aria-label={t('links.connectionTest')}
|
||||
>
|
||||
{t('links.connectionTest')}
|
||||
</Link>
|
||||
</StyledLi>
|
||||
<StyledLi>
|
||||
<A
|
||||
externalIcon
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"title": "Test your configuration",
|
||||
"intro": "Check that your device works with Visio: browser, media devices, and server connectivity.",
|
||||
"runTest": "Run test",
|
||||
"reset": "Run again",
|
||||
"details": "Details",
|
||||
"downloadReport": "Download report",
|
||||
"homeLink": "Test your configuration",
|
||||
"steps": {
|
||||
"browser": "Browser",
|
||||
"microphone": "Microphone",
|
||||
"camera": "Camera",
|
||||
"devices": "Media devices",
|
||||
"websocket": "WebSocket",
|
||||
"webrtc": "WebRTC",
|
||||
"turn": "TURN",
|
||||
"reconnect": "Reconnect",
|
||||
"publishAudio": "Audio publishing",
|
||||
"publishVideo": "Video publishing"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"running": "Running…",
|
||||
"success": "Passed",
|
||||
"failed": "Failed",
|
||||
"skipped": "Skipped"
|
||||
},
|
||||
"help": {
|
||||
"firewall": "If network tests fail, check your browser permissions and network filtering rules (WebRTC, WebSocket, TURN) with your IT department."
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,6 @@
|
||||
"legalsTerms": "Legal Notice",
|
||||
"data": "Personal Data and Cookies",
|
||||
"accessibility": "Accessibility: non-compliant",
|
||||
"connectionTest": "Test your configuration",
|
||||
"ariaLabel": "new window",
|
||||
"codeAnnotation": "Our code is open and available on this",
|
||||
"code": "Open Source Code Repository",
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
|
||||
"moreLink": "Learn more",
|
||||
"moreAbout": "about {{appTitle}}",
|
||||
"connectionTestLink": "Test your configuration",
|
||||
"createMenu": {
|
||||
"laterOption": "Create a meeting for a later date",
|
||||
"instantOption": "Start an instant meeting"
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"title": "Tester votre configuration",
|
||||
"intro": "Vérifiez la compatibilité de votre poste avec Visio : navigateur, périphériques médias et connexion au serveur.",
|
||||
"runTest": "Lancer le test",
|
||||
"reset": "Relancer",
|
||||
"details": "Détails",
|
||||
"downloadReport": "Télécharger le rapport",
|
||||
"homeLink": "Tester votre configuration",
|
||||
"steps": {
|
||||
"browser": "Navigateur",
|
||||
"microphone": "Microphone",
|
||||
"camera": "Caméra",
|
||||
"devices": "Périphériques médias",
|
||||
"websocket": "WebSocket",
|
||||
"webrtc": "WebRTC",
|
||||
"turn": "TURN",
|
||||
"reconnect": "Reconnexion",
|
||||
"publishAudio": "Publication audio",
|
||||
"publishVideo": "Publication vidéo"
|
||||
},
|
||||
"status": {
|
||||
"pending": "En attente",
|
||||
"running": "En cours…",
|
||||
"success": "Réussi",
|
||||
"failed": "Échec",
|
||||
"skipped": "Ignoré"
|
||||
},
|
||||
"help": {
|
||||
"firewall": "En cas d'échec des tests réseau, vérifiez vos permissions navigateur et les règles de filtrage réseau (WebRTC, WebSocket, TURN) auprès de votre service informatique."
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,6 @@
|
||||
"legalsTerms": "Mentions légales",
|
||||
"data": "Données personnelles et cookie",
|
||||
"accessibility": "Accessibilité : non conforme",
|
||||
"connectionTest": "Tester votre configuration",
|
||||
"ariaLabel": "nouvelle fenêtre",
|
||||
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
|
||||
"code": "dépôt de code Open Source",
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
|
||||
"moreLink": "En savoir plus",
|
||||
"moreAbout": "sur {{appTitle}}",
|
||||
"connectionTestLink": "Tester votre configuration",
|
||||
"createMenu": {
|
||||
"laterOption": "Créer une réunion pour une date ultérieure",
|
||||
"instantOption": "Démarrer une réunion instantanée"
|
||||
|
||||
@@ -20,9 +20,6 @@ const AccessibilityRoute = lazy(
|
||||
)
|
||||
const RoomRoute = lazy(() => import('@/features/rooms/routes/Room'))
|
||||
const FeedbackRoute = lazy(() => import('@/features/rooms/routes/Feedback'))
|
||||
const ConnectionTestRoute = lazy(
|
||||
() => import('@/features/connection-test/routes/ConnectionTest')
|
||||
)
|
||||
|
||||
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
|
||||
|
||||
@@ -30,7 +27,6 @@ export const routes: Record<
|
||||
| 'home'
|
||||
| 'room'
|
||||
| 'feedback'
|
||||
| 'connectionTest'
|
||||
| 'legalTerms'
|
||||
| 'accessibility'
|
||||
| 'termsOfService'
|
||||
@@ -61,11 +57,6 @@ export const routes: Record<
|
||||
path: '/feedback',
|
||||
Component: FeedbackRoute,
|
||||
},
|
||||
connectionTest: {
|
||||
name: 'connectionTest',
|
||||
path: '/test-connection',
|
||||
Component: ConnectionTestRoute,
|
||||
},
|
||||
legalTerms: {
|
||||
name: 'legalTerms',
|
||||
path: '/mentions-legales',
|
||||
|
||||
@@ -31,19 +31,6 @@ html.font-opendyslexic {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* LiveKit ConnectionCheck appends a temporary <video> to body during publishVideo.
|
||||
Keep it decodable (not display:none) but invisible. */
|
||||
body.connection-test-hide-livekit-video > video {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
* {
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user