diff --git a/CHANGELOG.md b/CHANGELOG.md index 770c43861..c26e9fe78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - ✨(backend) introduce a token exchange endpoint for iframe embeds +- ✨(all) allow any authenticated user to start a recording ### Changed diff --git a/docs/features/recording.md b/docs/features/recording.md index 270a62ff3..2d9657c53 100644 --- a/docs/features/recording.md +++ b/docs/features/recording.md @@ -3,7 +3,7 @@ La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned. -The feature allows users to record their room sessions. When a recording is complete, the room owner receives a notification with a link to download the recorded file. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`. +The feature allows users to record their room sessions. When a recording is complete, the user who started it and the room's owners and administrators receive a notification with a link to download the recorded file. The transcript summary is sent to the user who started the recording. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`. It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/). @@ -89,6 +89,7 @@ sequenceDiagram | Option | Type | Default | Description | | --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. | +| **RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED**| Boolean | `True` | When enabled, any authenticated participant currently connected to a room with the `trusted` or `public` access level can start and stop recordings. When disabled, only room admins and owners can. On `restricted` rooms, only admins and owners can record. | | **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. | | **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. | | **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. | diff --git a/docs/installation/kubernetes.md b/docs/installation/kubernetes.md index 414f3c322..a27a6fc39 100644 --- a/docs/installation/kubernetes.md +++ b/docs/installation/kubernetes.md @@ -408,6 +408,7 @@ These are the environmental options available on meet backend. | ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true | | ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | | | RECORDING_ENABLE | Record meeting option | false | +| RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED | Let any authenticated participant connected to a trusted or public room start/stop recordings. Set to false to reserve recording to room admins/owners | true | | RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings | | RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} | | RECORDING_EXPIRATION_DAYS | Recording expiration in days | | diff --git a/src/backend/core/api/__init__.py b/src/backend/core/api/__init__.py index 803b3e8fb..aed90922c 100644 --- a/src/backend/core/api/__init__.py +++ b/src/backend/core/api/__init__.py @@ -44,6 +44,9 @@ def get_frontend_configuration(request): "available_modes": settings.RECORDING_WORKER_CLASSES.keys(), "expiration_days": settings.RECORDING_EXPIRATION_DAYS, "max_duration": settings.RECORDING_MAX_DURATION, + "authenticated_participants_enabled": ( + settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED + ), }, "background_image": { "upload_is_enabled": settings.FILE_UPLOAD_ENABLED, diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 1fb44fa80..0c91c87b6 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -187,29 +187,29 @@ class IsPresentInMeeting(permissions.BasePermission): return False -class CanManageLobby(permissions.BasePermission): - """Grant lobby management (list/accept/deny waiting participants). +class ParticipantCapability(permissions.BasePermission): + """Base permission for in-meeting capabilities opened up to participants. - - Room admins/owners can always manage the lobby. - - When the room access level is TRUSTED, any authenticated user who is - currently connected to the meeting can manage the lobby. Presence is - verified cache-first (Redis), falling back to the LiveKit API. + - Room admins/owners always hold the capability. + - When the room access level is TRUSTED or PUBLIC, any + session-authenticated user who is currently connected to the meeting + holds it too. Access level is always read fresh from the DB; only presence is cached, - so changing the room to RESTRICTED takes effect immediately. + so changing the room access level takes effect immediately. """ - message = "You are not allowed to manage this room's lobby." + message = "You are not allowed to perform this action." + enabled_setting = None - # pylint: disable=too-many-return-statements - def has_object_permission(self, request, view, obj): # noqa: PLR0911 - """Check privileges first, then the trusted-room presence path.""" + def has_object_permission(self, request, view, obj): + """Check privileges first, then the participant presence path.""" user = request.user if not user or not user.is_authenticated: return False - # Product choice: lobby management is reserved for session-authenticated + # Product choice: these capabilities are reserved for session-authenticated # users with a real account, not holders of a LiveKit room token. if request.auth and hasattr(request.auth, "video"): return False @@ -217,16 +217,35 @@ class CanManageLobby(permissions.BasePermission): if obj.is_administrator_or_owner(user): return True - if obj.access_level != RoomAccessLevel.TRUSTED: + is_open_room = obj.access_level in ( + RoomAccessLevel.TRUSTED, + RoomAccessLevel.PUBLIC, + ) + is_enabled = not self.enabled_setting or getattr(settings, self.enabled_setting) + if not (is_open_room and is_enabled): return False - self.message = "You must be connected to the meeting to manage its lobby." - try: return ParticipantsManagement().check_if_in_meeting_cached( room_name=str(obj.pk), identity=str(user.sub) ) - except ParticipantNotFoundException: - return False - except ParticipantsManagementException: + except (ParticipantNotFoundException, ParticipantsManagementException): return False + + +class CanManageLobby(ParticipantCapability): + """Grant lobby management (list/accept/deny waiting participants). + + Public rooms have no lobby: the endpoints short-circuit there, so opening + them to participants exposes nothing. + """ + + +class CanManageRecording(ParticipantCapability): + """Grant starting/stopping a room recording (screen recording or transcript). + + Self-hosters can keep it admin/owner only with + `RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED`. + """ + + enabled_setting = "RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index b0ec01522..77ae982bc 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -404,7 +404,7 @@ class RoomViewSet( methods=["post"], url_path="start-recording", permission_classes=[ - permissions.HasPrivilegesOnRoom, + permissions.CanManageRecording, ], ) @FeatureFlag.require("recording") @@ -437,11 +437,7 @@ class RoomViewSet( mode=mode, options=options_data, ) - models.RecordingAccess.objects.create( - user=self.request.user, - role=models.RoleChoices.OWNER, - recording=recording, - ) + recording.grant_initial_accesses(starter=self.request.user) except (DjangoValidationError, IntegrityError): # DjangoValidationError covers the Python-level check (full_clean); @@ -485,7 +481,7 @@ class RoomViewSet( methods=["post"], url_path="stop-recording", permission_classes=[ - permissions.HasPrivilegesOnRoom, + permissions.CanManageRecording, ], ) @FeatureFlag.require("recording") diff --git a/src/backend/core/models.py b/src/backend/core/models.py index bcf6092b7..2e732b226 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -690,6 +690,29 @@ class Recording(BaseModel): "update": False, } + def grant_initial_accesses(self, starter): + """Make the starter and the room's owners/administrators OWNER of the recording. + + The starter's access is created first, so it is the oldest one: the + transcript summary, which goes to a single owner, is sent to them. + + Room roles are copied when the recording starts: later changes to the + room's accesses do not affect existing recordings. + """ + RecordingAccess.objects.create( + user=starter, role=RoleChoices.OWNER, recording=self + ) + + room_privileged_user_ids = ( + self.room.accesses.filter(role__in=[RoleChoices.OWNER, RoleChoices.ADMIN]) + .exclude(user=starter) + .values_list("user_id", flat=True) + ) + RecordingAccess.objects.bulk_create( + RecordingAccess(user_id=user_id, role=RoleChoices.OWNER, recording=self) + for user_id in room_privileged_user_ids + ) + def is_savable(self) -> bool: """Determine if the recording can be saved based on its current status.""" diff --git a/src/backend/core/recording/event/notification.py b/src/backend/core/recording/event/notification.py index 81624b1a3..060e8fb2e 100644 --- a/src/backend/core/recording/event/notification.py +++ b/src/backend/core/recording/event/notification.py @@ -222,6 +222,23 @@ class NotificationService: f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}" ) + @staticmethod + def _get_summary_recipient(recording: models.Recording): + """Return the single user who receives the transcript summary. + + A recording can have several owners (its starter and the room's + owners/administrators), but the summary is generated once. It goes to + the oldest owner, which is the starter: their access is created first + (see `Recording.grant_initial_accesses`). + """ + oldest_owner_access = ( + models.RecordingAccess.objects.select_related("user") + .filter(role=models.RoleChoices.OWNER, recording_id=recording.id) + .order_by("created_at") + .first() + ) + return oldest_owner_access.user if oldest_owner_access else None + @staticmethod def _notify_summary_service_v1(recording: models.Recording): """Notify summary service about a new recording.""" @@ -233,14 +250,7 @@ class NotificationService: logger.error("Summary service not configured") return False - owner_access = ( - models.RecordingAccess.objects.select_related("user") - .filter( - role=models.RoleChoices.OWNER, - recording_id=recording.id, - ) - .first() - ) + recipient = NotificationService._get_summary_recipient(recording) if settings.METADATA_COLLECTOR_ENABLED and recording.options.get( "collect_metadata", False @@ -250,7 +260,7 @@ class NotificationService: else: metadata_filename = None - if not owner_access: + if not recipient: logger.error("No owner found for recording %s", recording.id) return False @@ -259,16 +269,16 @@ class NotificationService: )(recording.worker_id) payload = { - "owner_id": str(owner_access.user.id), + "owner_id": str(recipient.id), "recording_filename": recording.key, "metadata_filename": metadata_filename, - "email": owner_access.user.email, - "sub": owner_access.user.sub, + "email": recipient.email, + "sub": recipient.sub, "room": recording.room.name, "language": recording.options.get("language"), - "owner_timezone": str(owner_access.user.timezone), + "owner_timezone": str(recipient.timezone), "download_link": f"{get_recording_download_base_url()}/{recording.id}", - "context_language": owner_access.user.language, + "context_language": recipient.language, "recording_start_at": (started_at.isoformat() if started_at else None), "recording_end_at": (ended_at.isoformat() if ended_at else None), } @@ -308,14 +318,7 @@ class NotificationService: logger.error("Summary service not configured") return False - owner_access = ( - models.RecordingAccess.objects.select_related("user") - .filter( - role=models.RoleChoices.OWNER, - recording_id=recording.id, - ) - .first() - ) + recipient = NotificationService._get_summary_recipient(recording) metadata_filename: None | str = None if settings.METADATA_COLLECTOR_ENABLED and recording.options.get( "collect_metadata", False @@ -323,7 +326,7 @@ class NotificationService: output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER metadata_filename = f"{output_folder}/{recording.id}-metadata.json" - if not owner_access: + if not recipient: logger.error("No owner found for recording %s", recording.id) return False @@ -350,8 +353,8 @@ class NotificationService: } payload = { - "user_sub": owner_access.user.sub, - "user_email": owner_access.user.email, + "user_sub": recipient.sub, + "user_email": recipient.email, "cloud_storage_url": generate_download_s3_url( recording.key, expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS, @@ -360,20 +363,20 @@ class NotificationService: "language": recording.options.get( "language", get_language().split("-")[0].lower() ), - "context_language": owner_access.user.language, + "context_language": recipient.language, "push_to_docs_config": { - "user_email": owner_access.user.email, + "user_email": recipient.email, "title": NotificationService._generate_title( - locale=owner_access.user.language + locale=recipient.language or recording.options.get("language", get_language()), room=recording.room.name, recording_datetime=started_at, - owner_timezone=str(owner_access.user.timezone), + owner_timezone=str(recipient.timezone), ), "download_link": f"{get_recording_download_base_url()}/{recording.id}", "form_link": form_link, "auto_create_summary": is_user_feature_flag_enabled( - owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED + recipient, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED ), }, "metadata": metadata_payload, diff --git a/src/backend/core/tests/recording/event/test_notification.py b/src/backend/core/tests/recording/event/test_notification.py index f40bb8404..2dc166065 100644 --- a/src/backend/core/tests/recording/event/test_notification.py +++ b/src/backend/core/tests/recording/event/test_notification.py @@ -479,3 +479,24 @@ def test_notify_summary_service_v2_payload_json_serializable_without_timestamps( assert isinstance(title, str) # ...so the payload serializes exactly the way ``requests`` serializes it. json.dumps(payload) + + +def test_summary_recipient_is_oldest_owner(): + """With several owners, the summary goes to the oldest one.""" + recording = factories.RecordingFactory() + oldest = factories.UserRecordingAccessFactory( + recording=recording, role=models.RoleChoices.OWNER + ) + for _ in range(2): + factories.UserRecordingAccessFactory( + recording=recording, role=models.RoleChoices.OWNER + ) + + assert NotificationService._get_summary_recipient(recording) == oldest.user + + +def test_summary_recipient_none_without_owner(): + """No owner: nobody to send the summary to.""" + recording = factories.RecordingFactory() + + assert NotificationService._get_summary_recipient(recording) is None diff --git a/src/backend/core/tests/rooms/test_api_rooms_recording_participants.py b/src/backend/core/tests/rooms/test_api_rooms_recording_participants.py new file mode 100644 index 000000000..924a81765 --- /dev/null +++ b/src/backend/core/tests/rooms/test_api_rooms_recording_participants.py @@ -0,0 +1,396 @@ +"""Trusted and public rooms: any authenticated participant present in the meeting can record.""" + +# pylint: disable=redefined-outer-name,unused-argument,protected-access + +from unittest import mock + +import pytest +from rest_framework.test import APIClient + +from core.factories import RecordingFactory, RoomFactory, UserFactory +from core.models import ( + Recording, + RecordingStatusChoices, + RoleChoices, + RoomAccessLevel, +) +from core.recording.event.notification import NotificationService +from core.services.presence import PresenceCache + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def mock_worker_manager(): + """Mock the worker service factory and mediator.""" + with ( + mock.patch("core.api.viewsets.get_worker_service"), + mock.patch("core.api.viewsets.WorkerServiceMediator") as mediator_class, + ): + mediator = mock.Mock() + mediator_class.return_value = mediator + yield mediator + + +@pytest.fixture(autouse=True) +def enable_recording(settings): + """Recording must be enabled for the endpoints to be reachable.""" + settings.RECORDING_ENABLE = True + + +@pytest.mark.parametrize( + "access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC] +) +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting", + return_value=True, +) +def test_open_room_present_user_can_start_recording( + mock_check, access_level, mock_worker_manager +): + """Authenticated + present in a trusted/public room -> 201, starter owns it.""" + user = UserFactory() + room = RoomFactory(access_level=access_level) + + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"} + ) + + assert response.status_code == 201 + mock_check.assert_called_once_with(room_name=str(room.id), identity=str(user.sub)) + recording = Recording.objects.get() + mock_worker_manager.start.assert_called_once_with(recording) + access = recording.accesses.get() + assert access.user == user + assert access.role == RoleChoices.OWNER + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting", + return_value=True, +) +def test_presence_is_cached_until_participant_leaves(mock_check, mock_worker_manager): + """Presence is memoized like for the lobby; clearing it forces a LiveKit re-check.""" + user = UserFactory() + room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) + RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE) + + client = APIClient() + client.force_login(user) + + url = f"/api/v1.0/rooms/{room.id}/stop-recording/" + + assert client.post(url).status_code == 200 + assert client.post(url).status_code == 200 + assert mock_check.call_count == 1 + + # participant_left webhook + PresenceCache().clear(room.id, str(user.sub)) + mock_check.return_value = False + + assert client.post(url).status_code == 403 + assert mock_check.call_count == 2 + + +@pytest.mark.parametrize( + "access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC] +) +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting", + return_value=False, +) +def test_open_room_absent_user_forbidden(mock_check, access_level, mock_worker_manager): + """Authenticated but not connected to the meeting -> 403.""" + room = RoomFactory(access_level=access_level) + user = UserFactory() + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"} + ) + + assert response.status_code == 403 + assert response.json() == {"detail": "You are not allowed to perform this action."} + assert Recording.objects.count() == 0 + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting", + return_value=True, +) +def test_restricted_room_present_user_forbidden(mock_check, mock_worker_manager): + """Presence is not enough on restricted rooms; LiveKit is not even asked.""" + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + user = UserFactory() + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"} + ) + + assert response.status_code == 403 + mock_check.assert_not_called() + assert Recording.objects.count() == 0 + + +@pytest.mark.parametrize( + "access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC] +) +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting", + return_value=True, +) +def test_open_room_present_user_can_stop_recording( + mock_check, access_level, mock_worker_manager +): + """Any present authenticated user can stop the active recording.""" + room = RoomFactory(access_level=access_level) + recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE) + user = UserFactory() + client = APIClient() + client.force_login(user) + response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/") + + assert response.status_code == 200 + mock_worker_manager.stop.assert_called_once_with(recording) + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting" +) +def test_admin_does_not_need_presence(mock_check, mock_worker_manager): + """Admins/owners keep recording rights on any room, without a LiveKit check.""" + user = UserFactory() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + room.accesses.create(user=user, role=RoleChoices.ADMIN) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"} + ) + + assert response.status_code == 201 + mock_check.assert_not_called() + + +@pytest.mark.parametrize( + "access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC] +) +def test_open_room_anonymous_forbidden(access_level): + """Anonymous users never record, even on public rooms.""" + room = RoomFactory(access_level=access_level) + response = APIClient().post( + f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"} + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC] +) +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting", + return_value=True, +) +def test_participant_path_disabled_by_setting( + mock_check, access_level, settings, mock_worker_manager +): + """Self-hosters can opt out: participants then get 403, LiveKit not asked.""" + settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False + room = RoomFactory(access_level=access_level) + RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE) + user = UserFactory() + client = APIClient() + client.force_login(user) + + start = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"} + ) + stop = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/") + + assert start.status_code == 403 + assert stop.status_code == 403 + mock_check.assert_not_called() + mock_worker_manager.start.assert_not_called() + mock_worker_manager.stop.assert_not_called() + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting" +) +def test_participant_path_disabled_admin_still_records( + mock_check, settings, mock_worker_manager +): + """The setting only removes the participant path, never admin rights.""" + settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False + user = UserFactory() + room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) + room.accesses.create(user=user, role=RoleChoices.OWNER) + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"} + ) + + assert response.status_code == 201 + mock_check.assert_not_called() + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting", + return_value=True, +) +def test_lobby_not_affected_by_recording_setting(mock_check, settings): + """The recording opt-out must not leak into the lobby capability.""" + settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False + room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) + user = UserFactory() + client = APIClient() + client.force_login(user) + response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/") + + assert response.status_code == 200 + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting", + return_value=True, +) +def test_public_room_lobby_is_open_but_empty(mock_check): + """Participants pass the lobby permission on public rooms, which have no lobby.""" + room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) + user = UserFactory() + client = APIClient() + client.force_login(user) + + listing = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/") + entry = client.post( + f"/api/v1.0/rooms/{room.id}/enter/", + {"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True}, + ) + + assert listing.status_code == 200 + assert listing.json() == {"participants": []} + assert entry.status_code == 404 + assert entry.json() == {"message": "Room has no lobby system."} + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_config_exposes_authenticated_participants_setting(settings, enabled): + """The frontend reads the setting from /config/ to gate the UI.""" + settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = enabled + + response = APIClient().get("/api/v1.0/config/") + + assert response.status_code == 200 + assert response.json()["recording"]["authenticated_participants_enabled"] is enabled + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting", + return_value=True, +) +def test_room_owners_and_admins_get_access_to_participant_recording( + mock_check, mock_worker_manager +): + """Starter and room owners/admins all own the recording; members get nothing.""" + starter = UserFactory() + room_owner = UserFactory() + room_admin = UserFactory() + room_member = UserFactory() + room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) + room.accesses.create(user=room_owner, role=RoleChoices.OWNER) + room.accesses.create(user=room_admin, role=RoleChoices.ADMIN) + room.accesses.create(user=room_member, role=RoleChoices.MEMBER) + client = APIClient() + client.force_login(starter) + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"} + ) + + assert response.status_code == 201 + recording = Recording.objects.get() + assert dict(recording.accesses.values_list("user_id", "role")) == { + starter.id: RoleChoices.OWNER, + room_owner.id: RoleChoices.OWNER, + room_admin.id: RoleChoices.OWNER, + } + + # Room admins can see and download it; plain members cannot. + client.force_login(room_admin) + assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 200 + client.force_login(room_member) + assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 404 + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting" +) +def test_room_admin_starter_is_not_duplicated(mock_check, mock_worker_manager): + """A room admin who starts the recording gets a single access.""" + starter = UserFactory() + co_owner = UserFactory() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + room.accesses.create(user=starter, role=RoleChoices.ADMIN) + room.accesses.create(user=co_owner, role=RoleChoices.OWNER) + client = APIClient() + client.force_login(starter) + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"} + ) + + assert response.status_code == 201 + recording = Recording.objects.get() + assert dict(recording.accesses.values_list("user_id", "role")) == { + starter.id: RoleChoices.OWNER, + co_owner.id: RoleChoices.OWNER, + } + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting", + return_value=True, +) +def test_participant_can_stop_recording_started_by_someone_else( + mock_check, mock_worker_manager +): + """Stopping is room-wide on trusted rooms, not tied to who started it.""" + room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) + recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE) + recording.accesses.create(user=UserFactory(), role=RoleChoices.OWNER) + user = UserFactory() + client = APIClient() + client.force_login(user) + + response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/") + + assert response.status_code == 200 + mock_worker_manager.stop.assert_called_once_with(recording) + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting", + return_value=True, +) +def test_summary_goes_to_starter_among_several_owners(mock_check, mock_worker_manager): + """The starter's access is created first, so the summary goes to them.""" + starter = UserFactory() + room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) + for _ in range(2): + room.accesses.create(user=UserFactory(), role=RoleChoices.OWNER) + client = APIClient() + client.force_login(starter) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "transcript"} + ) + + assert response.status_code == 201 + recording = Recording.objects.get() + assert recording.accesses.count() == 3 + assert NotificationService._get_summary_recipient(recording) == starter diff --git a/src/backend/core/tests/rooms/test_api_rooms_start_recording.py b/src/backend/core/tests/rooms/test_api_rooms_start_recording.py index d50354a75..bee782fb7 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_start_recording.py +++ b/src/backend/core/tests/rooms/test_api_rooms_start_recording.py @@ -11,7 +11,7 @@ from livekit import api as livekit_api from rest_framework.test import APIClient from ...factories import RoomFactory, UserFactory -from ...models import Recording +from ...models import Recording, RoomAccessLevel from ...recording.worker.exceptions import RecordingStartError pytestmark = pytest.mark.django_db @@ -42,8 +42,13 @@ def mock_worker_manager(mock_worker_service): yield mock_mediator -def test_start_recording_anonymous(): +@pytest.mark.parametrize( + "recording_enabled, expected_status", + [(True, 401), (False, 404)], +) +def test_start_recording_anonymous(settings, recording_enabled, expected_status): """Anonymous users should not be allowed to start room recordings.""" + settings.RECORDING_ENABLE = recording_enabled room = RoomFactory() client = APIClient() @@ -52,14 +57,14 @@ def test_start_recording_anonymous(): {"mode": "screen_recording"}, ) - assert response.status_code == 401 + assert response.status_code == expected_status assert Recording.objects.count() == 0 def test_start_recording_non_owner_and_non_administrator(settings): """Non-owner and Non-Administrator users should not be allowed to start room recordings.""" settings.RECORDING_ENABLE = True - room = RoomFactory() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) user = UserFactory() client = APIClient() client.force_login(user) diff --git a/src/backend/core/tests/rooms/test_api_rooms_stop_recording.py b/src/backend/core/tests/rooms/test_api_rooms_stop_recording.py index b003e49e8..0cb91da6a 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_stop_recording.py +++ b/src/backend/core/tests/rooms/test_api_rooms_stop_recording.py @@ -10,7 +10,7 @@ import pytest from rest_framework.test import APIClient from ...factories import RecordingFactory, RoomFactory, UserFactory -from ...models import Recording, RecordingStatusChoices +from ...models import Recording, RecordingStatusChoices, RoomAccessLevel from ...recording.worker.exceptions import RecordingStopError pytestmark = pytest.mark.django_db @@ -41,15 +41,20 @@ def mock_worker_manager(mock_worker_service): yield mock_mediator -def test_stop_recording_anonymous(): +@pytest.mark.parametrize( + "recording_enabled, expected_status", + [(True, 401), (False, 404)], +) +def test_stop_recording_anonymous(settings, recording_enabled, expected_status): """Anonymous users should not be allowed to stop room recordings.""" + settings.RECORDING_ENABLE = recording_enabled room = RoomFactory() RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE) client = APIClient() response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/") - assert response.status_code == 401 + assert response.status_code == expected_status # Verify recording status hasn't changed assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1 @@ -57,7 +62,7 @@ def test_stop_recording_anonymous(): def test_stop_recording_non_owner_and_non_administrator(settings): """Non-owner and Non-Administrator users should not be allowed to stop room recordings.""" settings.RECORDING_ENABLE = True - room = RoomFactory() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) user = UserFactory() RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE) client = APIClient() diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index ea285c5d2..c890aadd1 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -793,6 +793,14 @@ class Base(Configuration): RECORDING_ENABLE = values.BooleanValue( False, environ_name="RECORDING_ENABLE", environ_prefix=None ) + # Let any authenticated participant connected to a trusted or public room + # start/stop recordings. Disable to keep recording reserved to room + # admins/owners. + RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = values.BooleanValue( + True, + environ_name="RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED", + environ_prefix=None, + ) RECORDING_OUTPUT_FOLDER = values.Value( "recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None ) diff --git a/src/frontend/src/api/useConfig.ts b/src/frontend/src/api/useConfig.ts index 22b2e372e..640414d9f 100644 --- a/src/frontend/src/api/useConfig.ts +++ b/src/frontend/src/api/useConfig.ts @@ -36,6 +36,7 @@ export interface ApiConfig { available_modes?: RecordingMode[] expiration_days?: number max_duration?: number + authenticated_participants_enabled?: boolean } background_image: { upload_is_enabled: boolean diff --git a/src/frontend/src/features/recording/components/ScreenRecordingSidePanel.tsx b/src/frontend/src/features/recording/components/ScreenRecordingSidePanel.tsx index 2e429a9a0..bc5d8933f 100644 --- a/src/frontend/src/features/recording/components/ScreenRecordingSidePanel.tsx +++ b/src/frontend/src/features/recording/components/ScreenRecordingSidePanel.tsx @@ -26,6 +26,10 @@ import { useTranscriptionLanguage } from '@/features/settings' import { useMutateRecording } from '../hooks/useMutateRecording' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' +import { + useCanRecord, + useIsRecordingOpenToParticipants, +} from '../hooks/useCanRecord' import { FeatureFlags } from '@/features/analytics/enums' import { LimitDescription } from './LimitDescription' import { captureEvent, reportError } from '@/features/analytics/telemetry' @@ -39,6 +43,8 @@ export const ScreenRecordingSidePanel = () => { const [includeTranscript, setIncludeTranscript] = useState(false) const isAdminOrOwner = useIsAdminOrOwner() + const canRecord = useCanRecord() + const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants() const hasScreenRecordingAccess = useHasRecordingAccess( RecordingMode.ScreenRecording, @@ -116,11 +122,13 @@ export const ScreenRecordingSidePanel = () => { } } - if (!isAdminOrOwner) { + if (!canRecord) { return ( { RecordingMode.ScreenRecording, FeatureFlags.ScreenRecording ) - const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights( + const hasFeatureWithoutRecordingRights = useHasFeatureWithoutRecordingRights( RecordingMode.Transcript, FeatureFlags.Transcript ) const isAdminOrOwner = useIsAdminOrOwner() + const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants() const isMetadataCollectorEnabled = useIsMetadataCollectorEnabled() @@ -139,11 +141,13 @@ export const TranscriptSidePanel = () => { } } - if (hasFeatureWithoutAdminRights) { + if (hasFeatureWithoutRecordingRights) { return ( { + const roomData = useRoomData() + const { data: config } = useConfig() + + return ( + config?.recording?.authenticated_participants_enabled === true && + !!roomData?.id && + !!roomData?.access_level && + OPEN_ACCESS_LEVELS.includes(roomData.access_level) + ) +} + +export const useCanRecord = () => { + const isAdminOrOwner = useIsAdminOrOwner() + const { isLoggedIn } = useUser() + const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants() + + return ( + isAdminOrOwner || (isLoggedIn === true && isRecordingOpenToParticipants) + ) +} diff --git a/src/frontend/src/features/recording/hooks/useHasFeatureWithoutAdminRights.ts b/src/frontend/src/features/recording/hooks/useHasFeatureWithoutRecordingRights.ts similarity index 76% rename from src/frontend/src/features/recording/hooks/useHasFeatureWithoutAdminRights.ts rename to src/frontend/src/features/recording/hooks/useHasFeatureWithoutRecordingRights.ts index 290626fff..f022fea5e 100644 --- a/src/frontend/src/features/recording/hooks/useHasFeatureWithoutAdminRights.ts +++ b/src/frontend/src/features/recording/hooks/useHasFeatureWithoutRecordingRights.ts @@ -2,21 +2,21 @@ import { useFeatureFlagEnabled } from 'posthog-js/react' import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled' import type { RecordingMode } from '../types' import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled' -import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' +import { useCanRecord } from './useCanRecord' import type { FeatureFlags } from '@/features/analytics/enums' -export const useHasFeatureWithoutAdminRights = ( +export const useHasFeatureWithoutRecordingRights = ( mode: RecordingMode, featureFlag: FeatureFlags ) => { const featureEnabled = useFeatureFlagEnabled(featureFlag) const isAnalyticsEnabled = useIsAnalyticsEnabled() const isRecordingModeEnabled = useIsRecordingModeEnabled(mode) - const isAdminOrOwner = useIsAdminOrOwner() + const canRecord = useCanRecord() return ( (featureEnabled || !isAnalyticsEnabled) && isRecordingModeEnabled && - !isAdminOrOwner + !canRecord ) } diff --git a/src/frontend/src/features/recording/hooks/useHasRecordingAccess.ts b/src/frontend/src/features/recording/hooks/useHasRecordingAccess.ts index 896b83458..8f52362d9 100644 --- a/src/frontend/src/features/recording/hooks/useHasRecordingAccess.ts +++ b/src/frontend/src/features/recording/hooks/useHasRecordingAccess.ts @@ -2,7 +2,7 @@ import { useFeatureFlagEnabled } from 'posthog-js/react' import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled' import type { RecordingMode } from '../types' import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled' -import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' +import { useCanRecord } from './useCanRecord' import type { FeatureFlags } from '@/features/analytics/enums' export const useHasRecordingAccess = ( @@ -12,11 +12,11 @@ export const useHasRecordingAccess = ( const featureEnabled = useFeatureFlagEnabled(featureFlag) const isAnalyticsEnabled = useIsAnalyticsEnabled() const isRecordingModeEnabled = useIsRecordingModeEnabled(mode) - const isAdminOrOwner = useIsAdminOrOwner() + const canRecord = useCanRecord() return ( (featureEnabled || !isAnalyticsEnabled) && - isAdminOrOwner && + canRecord && isRecordingModeEnabled ) } diff --git a/src/frontend/src/features/recording/index.ts b/src/frontend/src/features/recording/index.ts index e9887a36b..b1a8fa8ea 100644 --- a/src/frontend/src/features/recording/index.ts +++ b/src/frontend/src/features/recording/index.ts @@ -1,7 +1,11 @@ // hooks export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled' export { useHasRecordingAccess } from './hooks/useHasRecordingAccess' -export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights' +export { useHasFeatureWithoutRecordingRights } from './hooks/useHasFeatureWithoutRecordingRights' +export { + useCanRecord, + useIsRecordingOpenToParticipants, +} from './hooks/useCanRecord' export { useRecordingStatuses } from './hooks/useRecordingStatuses' // api diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index f9f714fa0..d6b0d58c4 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -507,6 +507,22 @@ "buttonLabel": "Anfragen" } }, + "notLoggedIn": { + "heading": "Zum Transkribieren anmelden", + "body": "In diesem Meeting kann jede angemeldete Person eine Transkription (Beta) starten.", + "linkMore": "Dokumentation öffnen", + "linkAriaLabel": "Dokumentation zum Transkriptionszugang öffnen – öffnet in neuem Tab", + "dividerLabel": "ODER", + "login": { + "heading": "Anmeldung erforderlich", + "body": "Melden Sie sich an und kehren Sie dann zu diesem Bereich zurück, um die Transkription zu starten." + }, + "request": { + "heading": "Transkription anfragen", + "body": "Die Moderation wird benachrichtigt und kann die Transkription starten.", + "buttonLabel": "Anfragen" + } + }, "premium": { "heading": "Advanced-Funktion", "body": "Diese Funktion ist für Sie nicht verfügbar. Bitte wenden Sie sich an den Support, um weitere Informationen zu erhalten.", @@ -560,6 +576,22 @@ "buttonLabel": "Anfrage senden" } }, + "notLoggedIn": { + "heading": "Zum Aufnehmen anmelden", + "body": "In diesem Meeting kann jede angemeldete Person eine Videoaufnahme (Beta) starten.", + "linkMore": "Dokumentation öffnen", + "linkAriaLabel": "Dokumentation zum Aufzeichnungszugang öffnen – öffnet in neuem Tab", + "dividerLabel": "ODER", + "login": { + "heading": "Anmeldung erforderlich", + "body": "Melden Sie sich an und kehren Sie dann zu diesem Bereich zurück, um die Aufnahme zu starten." + }, + "request": { + "heading": "Aufnahme anfragen", + "body": "Die Moderation wird benachrichtigt und kann die Aufnahme starten.", + "buttonLabel": "Anfrage senden" + } + }, "premium": { "heading": "Advanced-Funktion", "body": "Diese Funktion ist für Sie nicht verfügbar. Bitte wenden Sie sich an den Support, um weitere Informationen zu erhalten.", diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 35f5d4235..dc46524ee 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -507,6 +507,22 @@ "buttonLabel": "Request" } }, + "notLoggedIn": { + "heading": "Log in to transcribe", + "body": "In this meeting, any logged-in participant can start a transcription (beta).", + "linkMore": "Open documentation", + "linkAriaLabel": "Open documentation about transcription access - opens in new window", + "dividerLabel": "OR", + "login": { + "heading": "Login Required", + "body": "Log in, then come back to this panel to start the transcription." + }, + "request": { + "heading": "Request Transcription", + "body": "The host will be notified and can enable transcription for you.", + "buttonLabel": "Request" + } + }, "premium": { "heading": "Advanced feature", "body": "This feature is not available to you. Please contact support for more information.", @@ -560,6 +576,22 @@ "buttonLabel": "Request" } }, + "notLoggedIn": { + "heading": "Log in to record", + "body": "In this meeting, any logged-in participant can start a recording (beta).", + "linkMore": "Open documentation", + "linkAriaLabel": "Open documentation about recording access - opens in new window", + "dividerLabel": "OR", + "login": { + "heading": "Login Required", + "body": "Log in, then come back to this panel to start the recording." + }, + "request": { + "heading": "Request Recording", + "body": "The host will be notified and can enable recording for you.", + "buttonLabel": "Request" + } + }, "premium": { "heading": "Advanced feature", "body": "This feature is not available to you. Please contact support for more information.", diff --git a/src/frontend/src/locales/es/rooms.json b/src/frontend/src/locales/es/rooms.json index caa239c8d..63c5610b1 100644 --- a/src/frontend/src/locales/es/rooms.json +++ b/src/frontend/src/locales/es/rooms.json @@ -506,6 +506,22 @@ "buttonLabel": "Solicitar" } }, + "notLoggedIn": { + "heading": "Inicia sesión para transcribir", + "body": "En esta reunión, cualquier participante con sesión iniciada puede iniciar una transcripción (beta).", + "linkMore": "Abrir la documentación", + "linkAriaLabel": "Abrir la documentación sobre el acceso a la transcripción - se abre en una nueva ventana", + "dividerLabel": "O", + "login": { + "heading": "Inicio de sesión necesario", + "body": "Inicia sesión y vuelve a este panel para iniciar la transcripción." + }, + "request": { + "heading": "Pedírselo al organizador", + "body": "El anfitrión recibirá una notificación y podrá iniciar la transcripción por ti.", + "buttonLabel": "Solicitar" + } + }, "premium": { "heading": "Función avanzada", "body": "Esta función no está disponible para ti. Ponte en contacto con el soporte para obtener más información.", @@ -559,6 +575,22 @@ "buttonLabel": "Solicitar" } }, + "notLoggedIn": { + "heading": "Inicia sesión para grabar", + "body": "En esta reunión, cualquier participante con sesión iniciada puede iniciar una grabación (beta).", + "linkMore": "Abrir la documentación", + "linkAriaLabel": "Abrir la documentación sobre el acceso a la grabación - se abre en una nueva ventana", + "dividerLabel": "O", + "login": { + "heading": "Inicio de sesión necesario", + "body": "Inicia sesión y vuelve a este panel para iniciar la grabación." + }, + "request": { + "heading": "Pedírselo al organizador", + "body": "El anfitrión recibirá una notificación y podrá iniciar la grabación por ti.", + "buttonLabel": "Solicitar" + } + }, "premium": { "heading": "Función avanzada", "body": "Esta función no está disponible para ti. Ponte en contacto con el soporte para obtener más información.", diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index bbfb5e06d..fa232c1d6 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -507,6 +507,22 @@ "buttonLabel": "Demander" } }, + "notLoggedIn": { + "heading": "Connectez-vous pour transcrire", + "body": "Dans cette réunion, tout participant connecté peut lancer une transcription (beta).", + "linkMore": "Ouvrir la documentation", + "linkAriaLabel": "Ouvrir la documentation sur l'accès à la transcription - ouvre dans une nouvelle fenêtre", + "dividerLabel": "OU", + "login": { + "heading": "Connexion requise", + "body": "Connectez-vous, puis revenez sur ce panneau pour lancer la transcription." + }, + "request": { + "heading": "Demander à l'organisateur", + "body": "L'hôte recevra une notification et pourra démarrer la transcription pour vous.", + "buttonLabel": "Demander" + } + }, "premium": { "heading": "Fonctionnalité avancée", "body": "Cette fonctionnalité ne vous est pas ouverte. Contactez le support pour obtenir plus d'informations.", @@ -560,6 +576,22 @@ "buttonLabel": "Demander" } }, + "notLoggedIn": { + "heading": "Connectez-vous pour enregistrer", + "body": "Dans cette réunion, tout participant connecté peut lancer un enregistrement (beta).", + "linkMore": "Ouvrir la documentation", + "linkAriaLabel": "Ouvrir la documentation sur l'accès à l'enregistrement - ouvre dans une nouvelle fenêtre", + "dividerLabel": "OU", + "login": { + "heading": "Connexion requise", + "body": "Connectez-vous, puis revenez sur ce panneau pour lancer l'enregistrement." + }, + "request": { + "heading": "Demander à l'organisateur", + "body": "L'hôte recevra une notification et pourra démarrer l'enregistrement pour vous.", + "buttonLabel": "Demander" + } + }, "premium": { "heading": "Fonctionnalité avancée", "body": "Cette fonctionnalité ne vous est pas ouverte. Contactez le support pour obtenir plus d'informations.", diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 07db7bfc1..581adadbc 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -507,6 +507,22 @@ "buttonLabel": "Aanvragen" } }, + "notLoggedIn": { + "heading": "Log in om te transcriberen", + "body": "In deze vergadering kan elke ingelogde deelnemer een transcriptie starten (beta).", + "linkMore": "Documentatie openen", + "linkAriaLabel": "Documentatie over transcriptietoegang openen - opent in nieuw venster", + "dividerLabel": "OF", + "login": { + "heading": "Inloggen vereist", + "body": "Log in en kom daarna terug naar dit paneel om de transcriptie te starten." + }, + "request": { + "heading": "Transcriptie aanvragen", + "body": "De host wordt op de hoogte gebracht en kan de transcriptie voor u inschakelen.", + "buttonLabel": "Aanvragen" + } + }, "premium": { "heading": "Geavanceerde functie", "body": "Deze functie is niet voor u beschikbaar. Neem contact op met de ondersteuning voor meer informatie.", @@ -560,6 +576,22 @@ "buttonLabel": "Aanvragen" } }, + "notLoggedIn": { + "heading": "Log in om op te nemen", + "body": "In deze vergadering kan elke ingelogde deelnemer een opname starten (beta).", + "linkMore": "Documentatie openen", + "linkAriaLabel": "Documentatie over opnametoegang openen - opent in nieuw venster", + "dividerLabel": "OF", + "login": { + "heading": "Inloggen vereist", + "body": "Log in en kom daarna terug naar dit paneel om de opname te starten." + }, + "request": { + "heading": "Opname aanvragen", + "body": "De host wordt op de hoogte gebracht en kan de opname voor u inschakelen.", + "buttonLabel": "Aanvragen" + } + }, "premium": { "heading": "Geavanceerde functie", "body": "Deze functie is niet voor u beschikbaar. Neem contact op met de ondersteuning voor meer informatie.",