✨(all) allow any authenticated user to start a recording

Any authenticated participant can now start a recording on trusted
and public rooms. This is essential, requested by many users, and
should encourage people to authenticate on public rooms. It also
means the starter may hold no role on the room, so who gets access
to the recording has to be decided explicitly.

The part open to discussion is the recording access: who should be
owner/admin on the recording. This commit picks the simplest
design:

* The room's owners and administrators get an `OWNER`
  `RecordingAccess`: they can retrieve, download, stop and delete
  the recording.
* The starter gets a `MEMBER` `RecordingAccess`, unless they already
  own or administer the room, in which case they get `OWNER`. A
  member can retrieve and download the recording but cannot delete
  it: deletion is irreversible and affects everyone with access, so
  it stays with the people responsible for the room.
* Everyone with an access, members included, receives the
  "recording ready" email.
* Room roles are copied when the recording starts: later changes
  to the room's accesses do not affect existing recordings.

The people responsible for a meeting should control its recording
and transcript, and whoever initiated it should be able to access
it on their own. The starter owns the output and is responsible for
sharing it with their colleagues.

Recording members could not retrieve a recording before this
commit. No code path created such accesses until now, but one added
manually through the admin now grants read access.

One constraint is respected: the summary service creates a doc,
and the docs API can only attribute it to a single owner. The
summary therefore goes to the oldest access, and the starter's
access is created before the room owners/admins ones, so the
starter receives it. The lookup does not filter on role, since the
starter may only be a member. The previous lookup took `.first()`
with the default newest-first ordering, which with several owners
would have picked a room admin.

Alternatives considered:

* Granting access to every participant allowed to record, or to
  those present when the recording starts: it requires listing
  participants from LiveKit and creating an access for each, and
  widens access by default, for a need no user has expressed.
* A `creator` attribute on the recording row: it only adds a
  migration without adding value to access control, so it was
  dropped.
* Restricting access to the link owner even when someone else
  starts the recording: some organizations may want this for
  security reasons, but it is YAGNI until a few large clients ask
  for it.

I think we should contribute to Docs' api to allow sharing the
transcription document with several users.
This commit is contained in:
lebaudantoine
2026-10-09 19:49:39 +02:00
parent 9bc712009a
commit ab6e58ecae
30 changed files with 860 additions and 106 deletions
+1
View File
@@ -12,6 +12,7 @@ and this project adheres to
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
- ✨(backend) add room soft-deletion to the external API
- ✨(all) allow any authenticated user to start a recording
## [1.35.0] - 2026-10-13
+7
View File
@@ -24,6 +24,13 @@ Deleting a room now only soft deletes it: it is hidden but keeps its slug and PI
- The new `ROOM_DELETED_RETENTION_DAYS` setting purges soft-deleted rooms that many days after their deletion, whether they are inactive or not. It is unset by default.
- As for inactive rooms, a deleted room holding a saved recording its users may still access is kept until that recording expires.
### Recording permission
We've introduced the ability for any authenticated participant to start a recording or transcription, but this behavior remains configurable.
You can keep recording and transcription restricted to room admins and owners by setting `RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED` to `false`.
## [v1.34.0]
### Marketing / Brevo integration now uses `django-lasuite`
The in-house marketing service (`core.services.marketing`) has been removed and
+2 -1
View File
@@ -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. |
+1
View File
@@ -410,6 +410,7 @@ These are the environmental options available on meet backend.
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
| ROOM_DELETED_RETENTION_DAYS | Days after its deletion after which a room is purged. Unset to purge deleted rooms only once inactive | |
| 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 | |
+3
View File
@@ -55,6 +55,9 @@ def get_frontend_configuration(request):
"default_resolution": settings.RECORDING_ENCODING_DEFAULT_RESOLUTION,
"default_profile": settings.RECORDING_ENCODING_DEFAULT_PROFILE,
},
"authenticated_participants_enabled": (
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED
),
},
"background_image": {
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
+33 -18
View File
@@ -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,31 @@ 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)."""
enabled_setting = "RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED"
+3 -7
View File
@@ -434,7 +434,7 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
methods=["post"],
url_path="start-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.CanManageRecording,
],
)
@FeatureFlag.require("recording")
@@ -467,11 +467,7 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
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);
@@ -515,7 +511,7 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
methods=["post"],
url_path="stop-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.CanManageRecording,
],
)
@FeatureFlag.require("recording")
+28 -1
View File
@@ -743,11 +743,38 @@ class Recording(BaseModel):
return {
"destroy": is_owner_or_admin and is_final_status,
"partial_update": False,
"retrieve": is_owner_or_admin,
"retrieve": bool(roles),
"stop": is_owner_or_admin and not is_final_status,
"update": False,
}
def grant_initial_accesses(self, starter):
"""Grant the starter and the room's owners/administrators access to the recording.
The starter's access is created first, so it is the oldest one: the
transcript summary, which goes to a single user, is sent to them.
Room roles are copied when the recording starts: later changes to the
room's accesses do not affect existing recordings.
"""
room_privileged_user_ids = set(
self.room.accesses.filter(
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN]
).values_list("user_id", flat=True)
)
starter_is_privileged = starter.id in room_privileged_user_ids
room_privileged_user_ids.discard(starter.id)
RecordingAccess.objects.create(
user=starter,
role=RoleChoices.OWNER if starter_is_privileged else RoleChoices.MEMBER,
recording=self,
)
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."""
@@ -68,7 +68,8 @@ class NotificationService:
@staticmethod
def _notify_user_by_email(recording) -> bool:
"""
Queue an email notification to recording owners when their recording is ready.
Queue an email notification to every user with access to the recording
when it is ready: its starter and the room's owners/administrators.
The email includes a direct link that redirects owners to a dedicated download
page in the frontend where they can access their specific recording.
@@ -80,10 +81,7 @@ class NotificationService:
owner_accesses = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.filter(recording_id=recording.id)
.order_by("created_at")
)
@@ -207,6 +205,27 @@ 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 multiple accesses (the starter and the room's
owners/administrators), but its summary is generated only once.
Temporarily, until the document can be created and shared with all
recording accesses, the summary is assigned to the oldest access,
which belongs to the starter (see `Recording.grant_initial_accesses`).
Since the starter may have a MEMBER role, the lookup must not filter by role.
"""
oldest_access = (
models.RecordingAccess.objects.select_related("user")
.filter(recording_id=recording.id)
.order_by("created_at")
.first()
)
return oldest_access.user if oldest_access else None
@staticmethod
def _notify_summary_service_v1(recording: models.Recording):
"""Notify summary service about a new recording."""
@@ -218,14 +237,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
@@ -235,7 +247,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
@@ -244,16 +256,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),
}
@@ -293,14 +305,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
@@ -308,7 +313,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
@@ -335,8 +340,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,
@@ -345,20 +350,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,
@@ -100,7 +100,7 @@ def test_notify_external_services_unknown_mode(caplog):
# pylint: disable=too-many-locals
def test_notify_user_by_email_success(mocked_current_site, settings):
"""Test successful email notification to recording owners."""
"""Test successful email notification to every user with access to the recording."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
@@ -133,12 +133,10 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
recording=recording, role=models.RoleChoices.OWNER, user=english_user
)
# Create non-owner users to verify they don't receive emails
# Members (e.g. a starter with no privileged room role) are notified too
member = factories.UserFactory(email="member@test.com", language="en-us")
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.MEMBER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.ADMIN
recording=recording, role=models.RoleChoices.MEMBER, user=member
)
notification_service = NotificationService()
@@ -147,7 +145,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
result = notification_service._notify_user_by_email(recording)
assert result is True
assert mock_send_mail.call_count == 3
assert mock_send_mail.call_count == 4
call_args_list = mock_send_mail.call_args_list
@@ -211,6 +209,9 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
assert recipients3 == ["john@test.com"]
assert sender3 == "notifications@acme.com"
_, _, _, recipients4 = call_args_list[3][0]
assert recipients4 == ["member@test.com"]
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
"""Test email notification when no owners are found."""
@@ -534,3 +535,37 @@ 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_access():
"""With several accesses, 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_is_member_starter():
"""A starter holding a MEMBER access still receives the summary."""
recording = factories.RecordingFactory()
starter = factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.MEMBER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
assert NotificationService._get_summary_recipient(recording) == starter.user
def test_summary_recipient_none_without_access():
"""No access: nobody to send the summary to."""
recording = factories.RecordingFactory()
assert NotificationService._get_summary_recipient(recording) is None
@@ -89,9 +89,9 @@ def test_api_recordings_media_auth_no_abilities():
assert response.status_code == 403
def test_api_recordings_media_auth_wrong_abilities():
def test_api_recordings_media_auth_member():
"""
Test that users with insufficient role permissions cannot download recordings
Members of a recording (e.g. its starter) are allowed to download it.
"""
user = UserFactory()
@@ -102,13 +102,14 @@ def test_api_recordings_media_auth_wrong_abilities():
UserRecordingAccessFactory(user=user, recording=recording, role="member")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
assert response.status_code == 200
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
@@ -47,7 +47,8 @@ def test_api_recording_retrieve_authenticated():
def test_api_recording_retrieve_members():
"""
A user who is a member of a recording should not be able to retrieve it.
A user who is a member of a recording (e.g. its starter) should be able
to retrieve it.
"""
user = UserFactory()
recording = RecordingFactory()
@@ -58,10 +59,8 @@ def test_api_recording_retrieve_members():
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 200
assert response.json()["id"] == str(recording.id)
def test_api_recording_retrieve_administrators(settings):
@@ -974,3 +974,19 @@ def test_request_entry_accepts_signed_credential(generate_config, _notify):
assert response.json()["livekit"] == {"token": "test-token"}
generate_config.assert_called_once()
assert generate_config.call_args.kwargs["participant_id"] == public_id
@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
@@ -0,0 +1,412 @@
"""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 is member."""
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.MEMBER
@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; 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_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
):
"""Room owners/admins own the recording, the starter is member; room 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.MEMBER,
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 an exclusive 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
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
return_value=True,
)
def test_participant_starter_can_retrieve_but_not_delete(
mock_check, mock_worker_manager
):
"""A starter without a privileged room role can download but not delete."""
starter = UserFactory()
room_owner = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
room.accesses.create(user=room_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()
recording.status = RecordingStatusChoices.SAVED
recording.save()
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 200
assert client.delete(f"/api/v1.0/recordings/{recording.id}/").status_code == 403
assert Recording.objects.filter(id=recording.id).exists()
client.force_login(room_owner)
assert client.delete(f"/api/v1.0/recordings/{recording.id}/").status_code == 204
assert not Recording.objects.filter(id=recording.id).exists()
@@ -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,24 @@ 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."""
@pytest.mark.parametrize("authenticated_participants_enabled", [True, False])
def test_start_recording_restricted_room_non_owner_and_non_administrator(
settings, authenticated_participants_enabled
):
"""
On restricted rooms, non-owner and non-administrator users should not be
allowed to start recordings, whether or not authenticated participants
are allowed to record.
"""
settings.RECORDING_ENABLE = True
room = RoomFactory()
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = (
authenticated_participants_enabled
)
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
@@ -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()
@@ -91,7 +91,7 @@ def test_models_recording_get_abilities_privileges_active(role):
def test_models_recording_get_abilities_member_active():
"""Test abilities for a user who is unprivileged."""
"""A member can retrieve the recording but not stop or delete it."""
user = UserFactory()
access = UserRecordingAccessFactory(role="member", user=user)
@@ -101,7 +101,7 @@ def test_models_recording_get_abilities_member_active():
assert abilities == {
"destroy": False,
"partial_update": False,
"retrieve": False,
"retrieve": True,
"stop": False,
"update": False,
}
@@ -129,7 +129,7 @@ def test_models_recording_get_abilities_privileges_final(role):
def test_models_recording_get_abilities_member_final():
"""Test abilities for a user who is unprivileged."""
"""A member can retrieve the recording but not stop or delete it."""
user = UserFactory()
access = UserRecordingAccessFactory(role="member", user=user)
@@ -139,7 +139,7 @@ def test_models_recording_get_abilities_member_final():
assert abilities == {
"destroy": False,
"partial_update": False,
"retrieve": False,
"retrieve": True,
"stop": False,
"update": False,
}
+8
View File
@@ -797,6 +797,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
)
+1
View File
@@ -43,6 +43,7 @@ export interface ApiConfig {
default_resolution?: string | null
default_profile?: string | null
}
authenticated_participants_enabled?: boolean
}
background_image: {
upload_is_enabled: boolean
@@ -31,6 +31,10 @@ import { useRecordingEncoding } from '../hooks/useRecordingEncoding'
import { openSettingsDialog } from '@/stores/settings'
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'
@@ -44,6 +48,8 @@ export const ScreenRecordingSidePanel = () => {
const [includeTranscript, setIncludeTranscript] = useState(false)
const isAdminOrOwner = useIsAdminOrOwner()
const canRecord = useCanRecord()
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
@@ -126,11 +132,13 @@ export const ScreenRecordingSidePanel = () => {
}
}
if (!isAdminOrOwner) {
if (!canRecord) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
i18nKey="notAdminOrOwner"
i18nKey={
isRecordingOpenToParticipants ? 'notLoggedIn' : 'notAdminOrOwner'
}
helpArticle={data?.support?.help_article_recording}
imagePath="/assets/intro-slider/4.png"
handleRequest={handleRequestScreenRecording}
@@ -6,7 +6,8 @@ import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasRecordingAccess,
useHasFeatureWithoutAdminRights,
useHasFeatureWithoutRecordingRights,
useIsRecordingOpenToParticipants,
useRecordingStatuses,
} from '../index'
import { useState } from 'react'
@@ -58,12 +59,13 @@ export const TranscriptSidePanel = () => {
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
const hasFeatureWithoutRecordingRights = useHasFeatureWithoutRecordingRights(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
const isAdminOrOwner = useIsAdminOrOwner()
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
const isMetadataCollectorEnabled = useIsMetadataCollectorEnabled()
@@ -143,11 +145,13 @@ export const TranscriptSidePanel = () => {
}
}
if (hasFeatureWithoutAdminRights) {
if (hasFeatureWithoutRecordingRights) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
i18nKey="notAdminOrOwner"
i18nKey={
isRecordingOpenToParticipants ? 'notLoggedIn' : 'notAdminOrOwner'
}
helpArticle={data?.support?.help_article_transcript}
imagePath="/assets/intro-slider/3.png"
handleRequest={handleRequestTranscription}
@@ -0,0 +1,30 @@
import { useConfig } from '@/api/useConfig'
import { useUser } from '@/features/auth/api/useUser'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
// Room access levels on which authenticated participants can record.
const OPEN_ACCESS_LEVELS = [ApiAccessLevel.TRUSTED, ApiAccessLevel.PUBLIC]
export const useIsRecordingOpenToParticipants = () => {
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)
)
}
@@ -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
)
}
@@ -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
)
}
+5 -1
View File
@@ -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'
export {
useRecordingEncoding,
+32
View File
@@ -516,6 +516,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.",
@@ -571,6 +587,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.",
+32
View File
@@ -516,6 +516,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.",
@@ -571,6 +587,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.",
+32
View File
@@ -515,6 +515,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.",
@@ -570,6 +586,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.",
+32
View File
@@ -516,6 +516,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.",
@@ -571,6 +587,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.",
+32
View File
@@ -516,6 +516,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.",
@@ -571,6 +587,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.",