Compare commits

..

6 Commits

Author SHA1 Message Date
lebaudantoine 0a2c4ce0fb ♻️(frontend) derive is_administrable from participant metadata
The is_administrable flag was previously read from the room API
response through the room serializer, giving the frontend static
information about the user's rights.

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

Two benefits:

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

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

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

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

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

More generally, this covers every action where, for security
reasons, we need to make sure the user is truly present in the call
and that someone is not reusing their cookie as an API key.
2026-07-24 18:33:30 +02:00
40 changed files with 703 additions and 861 deletions
-1
View File
@@ -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
-42
View File
@@ -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,
},
}
)
+32
View File
@@ -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
+11 -3
View File
@@ -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"]
-12
View File
@@ -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"
+51
View File
@@ -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:
-2
View File
@@ -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,
)
+178
View File
@@ -0,0 +1,178 @@
"""Room role management service.
Single entry point for changing a user's role on a room, used by:
- the in-meeting endpoint (promote/demote a connected participant)
- (more to come soon)
`ResourceAccess` is the source of truth. The LiveKit `room_role`
participant attribute is only a projection of it, synced best-effort.
"""
from logging import getLogger
from uuid import UUID
from core import models
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
logger = getLogger(__name__)
class RoomRoleError(Exception):
"""Base exception for room role management errors."""
status_code = 400
class SelfActionError(RoomRoleError):
"""Raised when a user tries to change their own role."""
status_code = 403
class OwnerRoleError(RoomRoleError):
"""Raised when trying to demote an owner or grant ownership."""
status_code = 403
class ParticipantNotInMeetingError(RoomRoleError):
"""Raised when the target participant is not connected to the meeting."""
status_code = 404
class UserNotFoundError(RoomRoleError):
"""Raised when the target participant has no user account in database."""
status_code = 404
ASSIGNABLE_ROLES = (models.RoleChoices.MEMBER, models.RoleChoices.ADMIN)
class RoomRoleService:
"""Manage promotion and demotion of room co-hosts."""
def set_role(
self, room: models.Room, user: models.User, role: str, actor: models.User
):
"""Persist `role` for `user` on `room`, idempotently and atomically.
Returns the up-to-date `ResourceAccess`. Never grants or removes
ownership: granting OWNER is refused, and an existing OWNER access
is never modified.
"""
if role not in ASSIGNABLE_ROLES:
raise OwnerRoleError("Ownership cannot be granted through this action.")
if actor is not None and user == actor:
raise SelfActionError("You cannot change your own role.")
access, created = models.ResourceAccess.objects.get_or_create(
resource=room,
user=user,
defaults={"role": role},
)
if created:
return access
if access.role == models.RoleChoices.OWNER:
raise OwnerRoleError("Room owners cannot be demoted.")
if access.role != role:
access.role = role
access.save(update_fields=["role", "updated_at"])
return access
def set_participant_role(
self,
room: models.Room,
participant_identity: UUID,
role: str,
actor: models.User,
):
"""Change the role of a participant currently connected to the meeting.
- The participant must be connected (checked against LiveKit).
- The participant must map to a user account.
- The role is persisted in DB then mirrored to LiveKit.
Returns a dict: {"role", "livekit_synced"}.
"""
room_name = str(room.pk)
participants_management = ParticipantsManagement()
try:
is_in_meeting = participants_management.check_if_in_meeting(
room_name=room_name, identity=str(participant_identity)
)
except ParticipantNotFoundException as e:
raise ParticipantNotInMeetingError(
"Participant is not connected to this meeting."
) from e
if not is_in_meeting:
raise ParticipantNotInMeetingError(
"Participant is not connected to this meeting."
)
user = models.User.objects.filter(sub=participant_identity).first()
if user is None:
raise UserNotFoundError(
"This participant has no user account and cannot be assigned a role."
)
# Source of truth first: even if the LiveKit sync below fails,
# the role is real and any fresh token will carry it.
self.set_role(room=room, user=user, role=role, actor=actor)
livekit_synced = self._sync_livekit_role(
room_name=room_name,
participant_identity=str(participant_identity),
role=str(role),
)
return {
"role": role,
"livekit_synced": livekit_synced,
}
@staticmethod
def _sync_livekit_role(room_name: str, participant_identity: str, role: str):
"""Mirror the role to the participant's LiveKit attributes.
Best-effort: returns False on failure instead of raising, so callers
can report a partial success. Re-running the action re-syncs.
"""
try:
ParticipantsManagement().update(
room_name=room_name,
identity=participant_identity,
attributes={"room_role": role},
)
except ParticipantNotFoundException:
# The participant left between the presence check and the update:
# harmless, the DB state (if any) remains authoritative.
logger.info(
"Participant %s left room %s before role sync",
participant_identity,
room_name,
)
return False
except ParticipantsManagementException:
logger.exception(
"Could not sync role to LiveKit for participant %s in room %s",
participant_identity,
room_name,
)
return False
return True
@@ -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
-6
View File
@@ -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
View File
@@ -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,
),
}
-15
View File
@@ -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'])
-10
View File
@@ -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."
}
}
-1
View File
@@ -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",
-1
View File
@@ -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."
}
}
-1
View File
@@ -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",
-1
View File
@@ -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"
-9
View File
@@ -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',
-13
View File
@@ -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;
}