From 72f40ecf48b846b97fa6cd47cb20971be02a5607 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Tue, 7 Jul 2026 19:21:12 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20pass=20the=20part?= =?UTF-8?q?icipant=20role=20in=20the=20LiveKit=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/backend/core/api/serializers.py | 2 +- src/backend/core/services/lobby.py | 2 -- src/backend/core/services/room_roles.py | 8 ++++---- .../test_api_rooms_participants_management.py | 20 +++++++++---------- .../tests/rooms/test_api_rooms_retrieve.py | 10 +++++----- .../test_api_rooms_update_participant_role.py | 2 +- src/backend/core/tests/services/test_lobby.py | 3 --- src/backend/core/utils.py | 13 ++++++------ .../components/ParticipantRow.tsx | 4 ++-- .../src/features/rooms/api/ApiRoom.ts | 2 ++ .../livekit/hooks/usePublishSourcesManager.ts | 4 ++-- .../rooms/utils/getParticipantIsRoomAdmin.ts | 12 ----------- .../utils/getParticipantIsRoomAdminOrOwner.ts | 20 +++++++++++++++++++ 13 files changed, 54 insertions(+), 48 deletions(-) delete mode 100644 src/frontend/src/features/rooms/utils/getParticipantIsRoomAdmin.ts create mode 100644 src/frontend/src/features/rooms/utils/getParticipantIsRoomAdminOrOwner.ts diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 0e1b5323..d9420ee4 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -183,7 +183,7 @@ class RoomSerializer(serializers.ModelSerializer): user=request.user, username=username, configuration=output["configuration"], - is_admin_or_owner=is_admin_or_owner, + role=role, ) else: del output["pin_code"] diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index 90ee4512..8c910d2f 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -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, ) diff --git a/src/backend/core/services/room_roles.py b/src/backend/core/services/room_roles.py index f7d7a0fa..0cb10363 100644 --- a/src/backend/core/services/room_roles.py +++ b/src/backend/core/services/room_roles.py @@ -4,7 +4,7 @@ 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_admin` +`ResourceAccess` is the source of truth. The LiveKit `room_role` participant attribute is only a projection of it, synced best-effort. """ @@ -138,7 +138,7 @@ class RoomRoleService: livekit_synced = self._sync_livekit_role( room_name=room_name, participant_identity=str(participant_identity), - is_admin=role == models.RoleChoices.ADMIN, + role=str(role), ) return { @@ -147,7 +147,7 @@ class RoomRoleService: } @staticmethod - def _sync_livekit_role(room_name: str, participant_identity: str, is_admin: bool): + 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 @@ -157,7 +157,7 @@ class RoomRoleService: ParticipantsManagement().update( room_name=room_name, identity=participant_identity, - attributes={"room_admin": "true" if is_admin else "false"}, + attributes={"room_role": role}, ) except ParticipantNotFoundException: # The participant left between the presence check and the update: diff --git a/src/backend/core/tests/rooms/test_api_rooms_participants_management.py b/src/backend/core/tests/rooms/test_api_rooms_participants_management.py index afd85d2f..1aa0a74f 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_participants_management.py +++ b/src/backend/core/tests/rooms/test_api_rooms_participants_management.py @@ -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( diff --git a/src/backend/core/tests/rooms/test_api_rooms_retrieve.py b/src/backend/core/tests/rooms/test_api_rooms_retrieve.py index e8c3bfe0..d27b13bf 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_retrieve.py +++ b/src/backend/core/tests/rooms/test_api_rooms_retrieve.py @@ -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 RoomAccessLevel, RoleChoices pytestmark = pytest.mark.django_db @@ -278,7 +278,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token): username=None, color=None, sources=["camera"], - is_admin_or_owner=False, + role=None, participant_id=None, ) @@ -330,7 +330,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token): username=None, color=None, sources=None, - is_admin_or_owner=False, + role=None, participant_id=None, ) @@ -418,7 +418,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti username=None, color=None, sources=["camera"], - is_admin_or_owner=False, + role=str(RoleChoices.MEMBER), participant_id=None, ) @@ -511,6 +511,6 @@ def test_api_rooms_retrieve_administrators( username=None, color=None, sources=None, - is_admin_or_owner=True, + role=str(user_access.role), participant_id=None, ) diff --git a/src/backend/core/tests/rooms/test_api_rooms_update_participant_role.py b/src/backend/core/tests/rooms/test_api_rooms_update_participant_role.py index 956fd5f3..6fd9e739 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_update_participant_role.py +++ b/src/backend/core/tests/rooms/test_api_rooms_update_participant_role.py @@ -120,7 +120,7 @@ def test_update_participant_role_promotes_authenticated_target( mock_sync.assert_called_once_with( room_name=str(room.pk), participant_identity=str(target.sub), - is_admin=True, + role="administrator", ) diff --git a/src/backend/core/tests/services/test_lobby.py b/src/backend/core/tests/services/test_lobby.py index ce7746c7..836f9800 100644 --- a/src/backend/core/tests/services/test_lobby.py +++ b/src/backend/core/tests/services/test_lobby.py @@ -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) diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 6d843e17..02d609c2 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -66,7 +66,7 @@ def generate_token( username: Optional[str] = None, color: Optional[str] = None, sources: Optional[List[str]] = None, - is_admin_or_owner: bool = False, + role: Optional[str] = None, participant_id: Optional[str] = None, ) -> str: """Generate a LiveKit access token for a user in a specific room. @@ -80,7 +80,7 @@ def generate_token( If none, a value will be generated sources: (Optional[List[str]]): List of media sources the user can publish If none, defaults to LIVEKIT_DEFAULT_SOURCES. - is_admin_or_owner (bool): Whether user has admin privileges + role (Optional[str]): Room's access role if any participant_id (Optional[str]): Stable identifier for anonymous users; used as identity when user.is_anonymous. @@ -88,6 +88,7 @@ def generate_token( str: The LiveKit JWT access token. """ + is_admin_or_owner = role in ("owner", "administrator") if is_admin_or_owner: sources = settings.LIVEKIT_DEFAULT_SOURCES @@ -128,7 +129,7 @@ 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 } ) ) @@ -139,7 +140,7 @@ def generate_livekit_config( room_id: str, user, username: str, - is_admin_or_owner: bool, + role: Optional[str] = None, color: Optional[str] = None, configuration: Optional[dict] = None, participant_id: Optional[str] = None, @@ -150,7 +151,7 @@ def generate_livekit_config( room_id: Room identifier user: User instance requesting access username: Display name in room - is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room. + role (str): Room's access role if any color (Optional[str]): Optional color to associate with the participant. configuration (Optional[dict]): Room configuration dict that can override default settings. participant_id (Optional[str]): Stable identifier for anonymous users; @@ -173,7 +174,7 @@ def generate_livekit_config( username=username, color=color, sources=sources, - is_admin_or_owner=is_admin_or_owner, + role=role, participant_id=participant_id, ), } diff --git a/src/frontend/src/features/participants/components/ParticipantRow.tsx b/src/frontend/src/features/participants/components/ParticipantRow.tsx index 78233a47..a5ddd2a5 100644 --- a/src/frontend/src/features/participants/components/ParticipantRow.tsx +++ b/src/frontend/src/features/participants/components/ParticipantRow.tsx @@ -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) => { )} - {getParticipantIsRoomAdmin(participant) && ( + {getParticipantIsRoomOwner(participant) && ( {t('participants.host')} )} diff --git a/src/frontend/src/features/rooms/api/ApiRoom.ts b/src/frontend/src/features/rooms/api/ApiRoom.ts index 5fb201a2..4e00bc3b 100644 --- a/src/frontend/src/features/rooms/api/ApiRoom.ts +++ b/src/frontend/src/features/rooms/api/ApiRoom.ts @@ -28,3 +28,5 @@ export type ApiRoom = { livekit?: ApiLiveKit configuration?: RoomConfiguration } + +export type ParticipantRole = 'member' | 'administrator' | 'owner' diff --git a/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts b/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts index b9b26e85..f5bd1666 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts @@ -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(() => { diff --git a/src/frontend/src/features/rooms/utils/getParticipantIsRoomAdmin.ts b/src/frontend/src/features/rooms/utils/getParticipantIsRoomAdmin.ts deleted file mode 100644 index e4afae46..00000000 --- a/src/frontend/src/features/rooms/utils/getParticipantIsRoomAdmin.ts +++ /dev/null @@ -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' -} diff --git a/src/frontend/src/features/rooms/utils/getParticipantIsRoomAdminOrOwner.ts b/src/frontend/src/features/rooms/utils/getParticipantIsRoomAdminOrOwner.ts new file mode 100644 index 00000000..e843a4ae --- /dev/null +++ b/src/frontend/src/features/rooms/utils/getParticipantIsRoomAdminOrOwner.ts @@ -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'])