Compare commits

...

5 Commits

Author SHA1 Message Date
Emmanuel Pelletier 27b0f44b2a 💄(dinum) Apply UI-Kit styles through custom CSS
Use hand-picked tokens from @gouvfr-lasuite/ui-tokens and apply them on
our components.
Depending on the context, we apply light or dark variants.
2026-10-19 13:19:55 +02:00
Emmanuel Pelletier 0b33806d52 💄(css) add [data-page] attr for easier theming through custom CSS
When customizing meet through the custom CSS file
(FRONTEND_CUSTOM_CSS_URL feature), it can be handy to easily know on
what page we are with CSS.
This exposes the current route on the `<html>` tag with a new
`[data-page]` attribute to allow easier targeting through custom CSS.
2026-10-19 13:15:53 +02:00
Emmanuel Pelletier 0fa8e45b3c 💄(css) add classes to components for easier theming through custom CSS
Since Panda generates atomic CSS, it's hard to modify styling through
custom CSS (FRONTEND_CUSTOM_CSS_URL feature).
This adds a few classes on some low-lvl components to allow easy
customization through the custom CSS file.
2026-10-19 13:13:18 +02:00
leo dce67e37f8 ✨(backend) add Menshen token-exchange authentication to external API
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another
service exchanges its user's access token for a token targeting Meet, then
calls the external API on behalf of that user. Tokens are validated by
introspection (RFC 7662). Menshen only reports a token as active when Meet
is among its audiences.

Add Menshen auth to the external API. Only activate it for room creation
scope (other scopes may be added later). If a user doesn't exist for the
provided sub,  create it, unless the provided email is already associated
to a an existing account.

This PR does not implement fallback to email to automatically create
user when no sub or an invalid sub is passed and email is provided, when
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True.
2026-10-16 08:33:46 +02:00
lebaudantoine ab6e58ecae ✨(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.
2026-10-15 09:55:57 +02:00
49 changed files with 1753 additions and 203 deletions
+2
View File
@@ -12,6 +12,8 @@ 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
- ✨(backend) authenticate external API calls with Menshen exchanged tokens
## [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
+193 -32
View File
@@ -1,35 +1,3 @@
:root {
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
}
.Header-beforeLogo {
display: block;
}
.Header-beforeLogo::before {
content: '';
display: block;
background-image: url(/assets/marianne.svg);
background-position: 0 -0.046875rem;
background-size: 2.0625rem 0.84375rem;
height: 0.75rem;
margin-bottom: 0.1rem;
width: 2.0625rem;
}
.Header-beforeLogo::after {
content: '';
display: block;
background-image: url(/assets/gouvernement.svg), url(/assets/devise.svg);
background-repeat: no-repeat, no-repeat;
background-size: 108.8px 10px, 40px 29px;
background-position: 0 3px, 0 18.9px;
width: 108.8px;
height: 48px;
margin-top: 0.25rem;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Regular-subset.woff2') format('woff2');
@@ -69,3 +37,196 @@
font-style: normal;
font-display: swap;
}
.Header-beforeLogo {
display: block;
}
.Header-beforeLogo::before {
content: '';
display: block;
background-image: url(/assets/marianne.svg);
background-position: 0 -0.046875rem;
background-size: 2.0625rem 0.84375rem;
height: 0.75rem;
margin-bottom: 0.1rem;
width: 2.0625rem;
}
.Header-beforeLogo::after {
content: '';
display: block;
background-image: url(/assets/gouvernement.svg), url(/assets/devise.svg);
background-repeat: no-repeat, no-repeat;
background-size: 108.8px 10px, 40px 29px;
background-position: 0 3px, 0 18.9px;
width: 108.8px;
height: 48px;
margin-top: 0.25rem;
}
:root {
--fonts-sans: Marianne, "Mona Sans VF", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans Backtick Fix", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--global-font-body: var(--fonts-sans);
--avatar-cap-height: 0.7;
/* use hand-picked variables from UI-kit and apply them to meet's variables */
--c--globals--colors--brand-050: #EDF0FF;
--c--globals--colors--brand-100: #DAE2FF;
--c--globals--colors--brand-250: #A2B6FF;
--c--globals--colors--brand-550: #3E5DE7;
--c--globals--colors--brand-600: #304DDF;
--c--globals--colors--brand-650: #2845C1;
--c--globals--colors--brand-700: #223E9E;
--c--globals--colors--gray-050: #F0F0F3;
--c--globals--colors--gray-100: #DFE2EA;
--c--globals--colors--gray-150: #CFD5DE;
--c--globals--colors--gray-250: #B2B9C7;
--c--globals--colors--gray-300: #A4ABBC;
--c--globals--colors--gray-550: #626A80;
--c--globals--colors--gray-600: #555E74;
--c--globals--colors--gray-650: #4A5267;
--c--globals--colors--gray-700: #3F4759;
--c--globals--colors--gray-750: #363B4C;
--c--globals--colors--gray-800: #2B303D;
--c--globals--colors--gray-850: #222631;
--c--globals--colors--gray-900: #181B24;
--c--globals--colors--white-050: #F6F8F90D;
--c--globals--colors--white-950: #F6F8F9F2;
--c--globals--colors--black-050: #181B240D;
--c--globals--colors--black-850: #181B24D9;
--c--globals--colors--error-550: #CF202D;
--c--globals--colors--error-650: #9D2227;
--colors-primary-800: var(--c--contextuals--background--semantic--brand--primary);
--colors-primary: var(--c--contextuals--background--semantic--brand--primary);
--colors-primary-action: var(--c--contextuals--background--semantic--brand--primary-hover);
--colors-default-subtle-text: var(--c--contextuals--content--semantic--neutral--secondary);
--colors-default-text: var(--c--contextuals--content--semantic--neutral--primary);
--colors-primary-subtle: var(--c--contextuals--background--semantic--brand--secondary);
--colors-primary-subtle-text: var(--c--contextuals--content--semantic--brand--secondary);
--colors-greyscale-250: var(--c--globals--colors--gray-100);
/* for the room's page background color */
--colors-primary-dark-50: var(--c--globals--colors--gray-900);
/* light theme specific UI-kit semantic values */
--c--contextuals--background--semantic--brand--primary: var(--c--globals--colors--brand-550);
--c--contextuals--background--semantic--brand--primary-hover: var(--c--globals--colors--brand-650);
--c--contextuals--content--semantic--brand--on-brand: var(--c--globals--colors--brand-050);
--c--contextuals--content--semantic--neutral--secondary: var(--c--globals--colors--gray-600);
--c--contextuals--content--semantic--neutral--primary: var(--c--globals--colors--gray-850);
--c--contextuals--background--semantic--brand--secondary: var(--c--globals--colors--brand-100);
--c--contextuals--content--semantic--brand--secondary: var(--c--globals--colors--brand-600);
--c--contextuals--content--semantic--brand--tertiary: var(--c--globals--colors--brand-550);
--c--contextuals--background--semantic--contextual--primary: var(--c--globals--colors--black-050);
--c--contextuals--content--semantic--contextual--primary: var(--c--globals--colors--white-950);
--c--contextuals--background--semantic--error--primary: var(--c--globals--colors--error-550);
--c--contextuals--background--semantic--error--primary-hover: var(--c--globals--colors--error-650);
--c--contextuals--background--semantic--neutral--tertiary: var(--c--globals--colors--gray-050);
--c--contextuals--border--semantic--neutral--tertiary: var(--c--globals--colors--gray-150);
--c--contextuals--content--semantic--neutral--tertiary: var(--c--globals--colors--gray-550);
/* try to match the UI-kit's control border color, but keep a 3:1 contrast ratio with white to pass WCAG rules on input borders */
--colors-control-border: #8996A9;
}
/* dark theme matching (room page) */
.Box--variant-dark,
.Tooltip,
#control-bar {
--c--contextuals--background--semantic--brand--primary: var(--c--globals--colors--brand-550);
--c--contextuals--background--semantic--brand--primary-hover: var(--c--globals--colors--brand-650);
--c--contextuals--content--semantic--brand--on-brand: var(--c--globals--colors--brand-050);
--c--contextuals--content--semantic--neutral--secondary: var(--c--globals--colors--gray-100);
--c--contextuals--content--semantic--neutral--primary: var(--c--globals--colors--gray-050);
--c--contextuals--background--semantic--brand--secondary: var(--c--globals--colors--brand-700);
--c--contextuals--content--semantic--brand--secondary: var(--c--globals--colors--brand-100);
--c--contextuals--content--semantic--brand--tertiary: var(--c--globals--colors--brand-250);
--c--contextuals--background--semantic--contextual--primary: var(--c--globals--colors--white-050);
--c--contextuals--content--semantic--contextual--primary: var(--c--globals--colors--black-850);
--c--contextuals--background--semantic--error--primary: var(--c--globals--colors--error-550);
--c--contextuals--background--semantic--error--primary-hover: var(--c--globals--colors--error-650);
--c--contextuals--background--semantic--neutral--secondary: var(--c--globals--colors--gray-700);
--c--contextuals--background--semantic--neutral--secondary-hover: var(--c--globals--colors--gray-750);
--c--contextuals--content--semantic--neutral--on-neutral: var(--c--globals--colors--gray-050);
--c--contextuals--background--surface--primary: var(--c--globals--colors--gray-800);
--c--contextuals--background--semantic--neutral--tertiary: var(--c--globals--colors--gray-750);
--c--contextuals--border--semantic--neutral--tertiary: var(--c--globals--colors--gray-700);
--c--contextuals--content--semantic--neutral--tertiary: var(--c--globals--colors--gray-250);
}
.Button--variant-primary {
background-color: var(--c--contextuals--background--semantic--brand--primary);
color: var(--c--contextuals--content--semantic--brand--on-brand);
}
.Button--variant-primary:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--brand--primary-hover);
}
.Button--variant-secondary {
border-color: #CFD5DE;
}
.Button--variant-secondaryText {
background: transparent;
color: var(--c--contextuals--content--semantic--brand--tertiary);
}
.Button--variant-secondaryText:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--contextual--primary);
}
.Button--variant-primaryDark {
background-color: var(--c--contextuals--background--semantic--neutral--secondary);
color: var(--c--contextuals--content--semantic--neutral--secondary);
}
.Button--variant-primaryDark:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--neutral--secondary-hover);
}
.Button--variant-danger {
background-color: var(--c--contextuals--background--semantic--error--primary);
color: var(--c--contextuals--content--semantic--brand--on-brand);
}
.Button--variant-danger:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--error--primary-hover);
}
.Button--variant-primaryTextDark {
background: transparent;
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
.Button--variant-primaryTextDark:is([data-hovered], [data-pressed]) {
background-color: var(--c--contextuals--background--semantic--contextual--primary);
}
.Box--variant-dark.Box--type-popover {
background-color: var(--c--contextuals--background--surface--primary);
}
.Tooltip {
background: var(--c--contextuals--background--semantic--neutral--tertiary);
border-color: var(--c--contextuals--border--semantic--neutral--tertiary);
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
/* switch component */
.indicator,
[data-selected="true"] .indicator::before {
background-color: var(--c--contextuals--content--semantic--contextual--primary);
}
.indicator .cross {
color: var(--c--contextuals--content--semantic--contextual--primary);
}
label:not([data-selected="true"]) .indicator::before,
[data-selected="true"] .indicator {
background: var(--colors-primary-800);
}
/* end switch component */
/* prevent impacting the toolbar buttons (primaryDark variant) */
.Button--size-default:is(.Button--variant-primary, .Button--variant-secondary) {
padding-block: 0.4375rem;
}
+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")
@@ -82,9 +82,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"User info contained an invalid sub claim"
) from err
if len(sub) > 255:
raise SuspiciousOperation("User info contained an invalid sub claim")
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
+204 -42
View File
@@ -4,21 +4,113 @@
# ruff: noqa: PLR0913
import logging
from dataclasses import asdict
from functools import lru_cache
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
from django.core.exceptions import SuspiciousOperation, ValidationError
import requests
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from menshen_client import Configuration, IntrospectionRequest, TokenExchangeClient
from menshen_client.exceptions import ResponseParsingError
from rest_framework import authentication, exceptions
from core.models import Application
from core.models import Application, ApplicationScope
from core.services import jwt_token
from core.validators import sub_validator
User = get_user_model()
logger = logging.getLogger(__name__)
def get_token(request, token_type):
"""Extract the token from the Authorization header.
Args:
request: The incoming request
token_type: Expected Authorization scheme (e.g. Bearer), matched
case-insensitively
Returns:
Token string, or None if the request carries no token of this type
Raises:
AuthenticationFailed: If the Authorization header is malformed
"""
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != token_type.lower().encode():
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
return auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
def get_or_create_user_by_sub(sub, email=None):
"""Retrieve the user matching a token subject, creating it if allowed.
Shared by the resource server backends, so that tokens introspected with the
OIDC provider or with Menshen resolve users the same way.
Args:
sub: Subject identifier from the introspected token
email: Email to store on a newly created user
Returns:
User instance, or None if unknown and OIDC_CREATE_USER is unset
Raises:
SuspiciousOperation: If the sub is invalid, the email of a user to create
is already used, or the user account is disabled
"""
if sub is not None:
sub = str(sub)
try:
sub_validator(sub)
except ValidationError as err:
raise SuspiciousOperation("User info contained an invalid sub claim") from err
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist:
if not settings.OIDC_CREATE_USER:
return None
# Users are never identified by email here. But a second account with the
# same email (e.g. provisionnal account) would break the lookups by email:
# refuse it.
if (
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and email
and User.objects.filter(email__iexact=email).exists()
):
logger.warning("Email already used, not creating user: %s", sub)
raise SuspiciousOperation(
"Email already used by another account."
) from None
user = User(sub=sub, email=email)
user.set_unusable_password()
user.save()
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
@@ -72,25 +164,12 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
if not self.is_enabled:
return None
auth_header = authentication.get_authorization_header(request).split()
token = get_token(request, self._token_type)
if (
not auth_header
or auth_header[0].lower() != self._token_type.lower().encode()
):
if token is None:
# Defer to next authentication backend
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
token = auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
return self.authenticate_credentials(token)
def decode_jwt(self, token):
@@ -256,6 +335,113 @@ class AddonsJWTAuthentication(BaseJWTAuthentication):
)
# Menshen grants scopes following La Suite's "service:resource:action" convention.
# Map them to the scopes expected by the external API permissions.
MENSHEN_SCOPES_MAPPING = {
"meet:room:create": ApplicationScope.ROOMS_CREATE,
}
@lru_cache(1)
def _get_token_exchange_client():
return TokenExchangeClient(
config=Configuration(
settings.MENSHEN_CLIENT_ID,
settings.MENSHEN_CLIENT_SECRET,
settings.MENSHEN_SERVER_URL,
)
)
class MenshenAuthentication(authentication.BaseAuthentication):
"""Authentication for tokens exchanged through Menshen.
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another service
exchanges its user's access token for a token targeting Meet, then calls the external
API on behalf of that user. Tokens are validated by introspection (RFC 7662). Menshen
only reports a token as active when Meet is among its audiences.
"""
def authenticate(self, request):
"""Introspect the bearer token with Menshen.
Returns:
Tuple of (user, payload) if the token is active, None otherwise
"""
if not settings.MENSHEN_ENABLED:
return None
token = get_token(request, "bearer")
if token is None:
return None
client = _get_token_exchange_client()
try:
response = client.introspect(IntrospectionRequest(token=token))
except (requests.RequestException, ResponseParsingError) as e:
logger.warning("Menshen introspection failed: %s", e)
return None
# Permission classes expect a dict payload in request.auth
payload = asdict(response)
if not payload.get("active"):
# Not a Menshen token, or an expired or revoked one: defer to next
# authentication backend
return None
user = self.get_or_create_user(payload)
scopes = payload.get("scope") or ""
payload["scope"] = [
MENSHEN_SCOPES_MAPPING[scope]
for scope in scopes.split()
if scope in MENSHEN_SCOPES_MAPPING
]
return (user, payload)
def get_or_create_user(self, payload):
"""Retrieve or create the user from the introspection response.
Menshen forwards the `sub` and `email` of the subject token, as introspected
with the OIDC provider.
Args:
payload: Introspection response dict
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
sub = payload.get("sub")
if not sub:
logger.warning("Missing 'sub' in Menshen introspection response")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = get_or_create_user_by_sub(sub, email=payload.get("email"))
except SuspiciousOperation as e:
raise exceptions.AuthenticationFailed(str(e)) from e
if user is None:
logger.warning("User not found: %s", sub)
raise exceptions.AuthenticationFailed("User not found.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "bearer"
class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval."""
@@ -285,28 +471,4 @@ class ResourceServerBackend(LaSuiteBackend):
logger.debug(message)
raise SuspiciousOperation(message)
user = self.get_user(access_token, id_token, payload)
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
def create_user(self, sub):
"""Create new user from subject claim.
Args:
sub: Subject identifier from token
Returns:
Newly created User instance
"""
user = self.UserModel(sub=sub)
user.set_unusable_password()
user.save()
return user
return get_or_create_user_by_sub(sub)
@@ -174,6 +174,7 @@ class RoomViewSet(
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
authentication.MenshenAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
+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()
@@ -35,6 +35,20 @@ def test_user_access_token_users_me():
assert response.data["email"] == user.email
def test_user_access_token_custom_token_type(settings):
"""The Authorization scheme should match the configured token type."""
settings.USER_ACCESS_TOKEN_TYPE = "Token"
user = UserFactory()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Token {generate_user_access_token(user)}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert response.data["email"] == user.email
def test_user_access_token_expired():
"""An expired user access token should be rejected."""
user = UserFactory()
@@ -67,6 +67,19 @@ def test_resource_server_backend_get_or_create_user_no_creation(settings):
assert not User.objects.filter(sub="new-sub").exists()
def test_resource_server_backend_get_or_create_user_invalid_sub(settings):
"""A sub longer than 255 characters should be rejected without creating a user."""
settings.OIDC_CREATE_USER = True
with pytest.raises(SuspiciousOperation, match="invalid sub claim"):
ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("a" * 256)
)
assert not User.objects.exists()
@responses.activate
def test_api_rooms_list_resource_server_inactive_user(settings):
"""End to end: a valid introspected token for an inactive user should get 401."""
@@ -0,0 +1,304 @@
"""Tests for the external API MenshenAuthentication."""
from unittest import mock
import pytest
import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import UserFactory
from core.models import RoleChoices, Room, User
pytestmark = pytest.mark.django_db
SERVER_URL = "https://menshen.example.com"
INTROSPECTION_ENDPOINT = f"{SERVER_URL}/auth/token/introspect/"
TOKEN = "menshen-exchanged-token"
@pytest.fixture(autouse=True)
def menshen_settings(settings):
"""Enable Menshen authentication."""
settings.MENSHEN_ENABLED = True
settings.MENSHEN_SERVER_URL = SERVER_URL
settings.MENSHEN_CLIENT_ID = "meet"
settings.MENSHEN_CLIENT_SECRET = "meet-secret"
def _introspection(sub, scope="meet:room:create", **overrides):
return {
"active": True,
"sub": sub,
"email": "user@example.com",
"scope": scope,
"aud": "meet",
"client_id": "menshen",
**overrides,
}
def _create_room(token=TOKEN):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
return client.post("/external-api/v1.0/rooms/", {}, format="json")
@responses.activate
def test_menshen_active_token():
"""An active token with a mapped scope should create a room owned by the user."""
user = UserFactory()
# Catch and mock external HTTP requests
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub),
match=[responses.matchers.urlencoded_params_matcher({"token": TOKEN})],
)
response = _create_room()
assert response.status_code == 201
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert responses.calls[0].request.headers["Authorization"].startswith("Basic ")
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_other_token_type_ignored(mock_rs_authenticate):
"""A token sent with another scheme than Bearer should not be introspected."""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Token {TOKEN}")
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 401
assert len(responses.calls) == 0
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_disabled(mock_rs_authenticate, settings):
"""Menshen should not be called when disabled."""
settings.MENSHEN_ENABLED = False
response = _create_room()
assert response.status_code == 401
assert len(responses.calls) == 0
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_inactive_token_defers(mock_rs_authenticate):
"""An inactive token should defer to the next authentication backend."""
user = UserFactory()
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub, scope="meet:room:create", active=False),
)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_error_defers(mock_rs_authenticate):
"""A Menshen error should defer to the next authentication backend."""
responses.add(responses.POST, INTROSPECTION_ENDPOINT, status=500)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_unparsable_response_defers(mock_rs_authenticate):
"""A response the client can't parse should defer to the next backend."""
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json={"active": True, "unexpected": "field"},
)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
def test_menshen_resource_server_token(settings):
"""End to end: a token issued by the OIDC provider is unknown to Menshen and
should still authenticate through the resource server."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
user = UserFactory()
token = "oidc-access-token"
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json={"active": False},
match=[responses.matchers.urlencoded_params_matcher({"token": token})],
)
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": True,
"sub": user.sub,
"scope": "openid lasuite_meet rooms:create",
"client_id": "app",
},
)
response = _create_room(token)
assert response.status_code == 201
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
responses.assert_call_count(INTROSPECTION_ENDPOINT, 1)
responses.assert_call_count("https://oidc.example.com/introspect", 1)
@responses.activate
def test_menshen_unmapped_scope():
"""Scopes granted for other services or other Meet actions should not grant access."""
user = UserFactory()
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub, scope="drive:item:create meet:room:list"),
)
response = _create_room()
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
assert not Room.objects.exists()
@responses.activate
def test_menshen_missing_sub():
"""An active token without sub should be rejected."""
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(None))
response = _create_room()
assert response.status_code == 401
assert "invalid token claims." in str(response.data).lower()
@responses.activate
def test_menshen_invalid_sub(settings):
"""An active token with an invalid sub should be rejected without creating a user."""
settings.OIDC_CREATE_USER = True
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("invalid\nsub")
)
response = _create_room()
assert response.status_code == 401
assert "invalid sub claim" in str(response.data).lower()
assert not User.objects.exists()
@responses.activate
def test_menshen_inactive_user():
"""An active token for an inactive user should be rejected."""
user = UserFactory(is_active=False)
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(user.sub))
response = _create_room()
assert response.status_code == 401
assert "user account is disabled." in str(response.data).lower()
@responses.activate
def test_menshen_unknown_user_created(settings):
"""An unknown sub should create a user when OIDC_CREATE_USER is set."""
settings.OIDC_CREATE_USER = True
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
assert response.status_code == 201
user = User.objects.get(sub="new-sub")
assert user.email == "user@example.com"
assert user.is_active is True
@responses.activate
def test_menshen_unknown_user_not_created(settings):
"""An unknown sub should be rejected when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
assert response.status_code == 401
assert "user not found." in str(response.data).lower()
assert not User.objects.filter(sub="new-sub").exists()
@responses.activate
@pytest.mark.parametrize("existing_sub", [None, "other-sub"])
def test_menshen_unknown_user_email_already_used(settings, existing_sub):
"""An unknown sub should not create a second account with an email already
used, by a provisional user or a user with another sub, when
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True."""
settings.OIDC_CREATE_USER = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
existing_user = UserFactory(sub=existing_sub, email="User@Example.com")
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
assert response.status_code == 401
assert "email already used by another account." in str(response.data).lower()
assert User.objects.count() == 1
existing_user.refresh_from_db()
assert existing_user.sub == existing_sub
assert not Room.objects.exists()
@@ -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,
}
+4 -1
View File
@@ -69,7 +69,10 @@ def test_models_users_sub_validator(sub, is_valid):
else:
with pytest.raises(
ValidationError,
match="Enter a valid sub. This value should be printable ASCII only.",
match=(
"Enter a valid sub. This value should be printable ASCII only, "
"with 255 characters or less."
),
):
user.full_clean()
+12 -3
View File
@@ -17,7 +17,16 @@ def sub_validator(value):
``isprintable()`` is false exactly for those control characters, while
space (U+0020) remains allowed.
"""
msg = (
"Enter a valid sub. This value should be printable ASCII only, "
"with 255 characters or less."
)
if value is None:
raise ValidationError(_(msg))
if len(value) > 255:
raise ValidationError(_(msg))
if not value.isascii() or not value.isprintable():
raise ValidationError(
_("Enter a valid sub. This value should be printable ASCII only.")
)
raise ValidationError(_(msg))
+22
View File
@@ -709,6 +709,20 @@ class Base(Configuration):
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
)
# Menshen, La Suite's OAuth 2.0 token exchange server
MENSHEN_ENABLED = values.BooleanValue(
False, environ_name="MENSHEN_ENABLED", environ_prefix=None
)
MENSHEN_SERVER_URL = values.Value(
None, environ_name="MENSHEN_SERVER_URL", environ_prefix=None
)
MENSHEN_CLIENT_ID = values.Value(
None, environ_name="MENSHEN_CLIENT_ID", environ_prefix=None
)
MENSHEN_CLIENT_SECRET = SecretFileValue(
None, environ_name="MENSHEN_CLIENT_SECRET", environ_prefix=None
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
@@ -797,6 +811,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
@@ -49,6 +49,7 @@ dependencies = [
"gunicorn==26.2.0",
"jsonschema==4.26.0",
"markdown==3.10.3",
"menshen-client==0.2.0",
"nested-multipart-parser==1.6.0",
"posthog==7.44.0",
"psycopg[binary]==3.3.4",
+15 -1
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 5
requires-python = ">=3.13"
[[package]]
@@ -1327,6 +1327,7 @@ dependencies = [
{ name = "jsonschema" },
{ name = "livekit-api" },
{ name = "markdown" },
{ name = "menshen-client" },
{ name = "mozilla-django-oidc" },
{ name = "nested-multipart-parser" },
{ name = "phonenumbers" },
@@ -1392,6 +1393,7 @@ requires-dist = [
{ name = "jsonschema", specifier = "==4.26.0" },
{ name = "livekit-api", specifier = "==1.2.0" },
{ name = "markdown", specifier = "==3.10.3" },
{ name = "menshen-client", specifier = "==0.2.0" },
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "phonenumbers", specifier = "==9.0.37" },
@@ -1428,6 +1430,18 @@ dev = [
{ name = "types-requests", specifier = "==2.33.0.20260712" },
]
[[package]]
name = "menshen-client"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/a2/5862b0a39d5f96301e1daff146445f591b1980412c20417162f5cfd6aa34/menshen_client-0.2.0.tar.gz", hash = "sha256:548c4224c38b2c844a76325b5a4b65da520ea9eba23aafb004da0f430a7e4fcf", size = 6326, upload-time = "2026-10-13T10:19:34.344Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/4d/932efd81c340d12d07b1c33633c6e7a4a2d0c9086ac0da7ffe9a1ce17068/menshen_client-0.2.0-py3-none-any.whl", hash = "sha256:415eaba30184452bc393b528c6088ed6e37cbd00ce60716aef9e2d9bd0697221", size = 7149, upload-time = "2026-10-13T10:19:33.272Z" },
]
[[package]]
name = "mozilla-django-oidc"
version = "5.0.2"
+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,
@@ -18,6 +18,7 @@ import { LogLevel, setLogLevel } from 'livekit-client'
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
import { usePageDataAttribute } from '@/navigation/usePageDataAttribute'
const BaseRoom = ({ children }: { children: ReactNode }) => {
return (
@@ -38,10 +39,12 @@ const Room = () => {
const initialRoomData = history.state?.initialRoomData
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
const skipJoinScreen = isLoggedIn && mode === 'create'
const isJoinScreen = !hasSubmittedEntry && !skipJoinScreen
const { data } = useConfig()
useRoomPageTitle(roomId)
usePageDataAttribute(isJoinScreen ? 'join' : 'conference')
useEffect(() => {
const shouldSilenceLogs = data?.silence_livekit_debug_logs || false
@@ -75,7 +78,7 @@ const Room = () => {
return <ErrorScreen />
}
if (!hasSubmittedEntry && !skipJoinScreen) {
if (isJoinScreen) {
return (
<BaseRoom>
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
+3
View File
@@ -6,6 +6,7 @@ import { useSnapshot } from 'valtio'
import { Footer } from '@/layout/Footer'
import { ScreenReaderAnnouncer } from '@/primitives'
import { SkipLink, MAIN_CONTENT_ID } from './SkipLink'
import { usePageDataAttribute } from '@/navigation/usePageDataAttribute'
export type Layout = 'fullpage' | 'centered'
@@ -16,6 +17,8 @@ export type Layout = 'fullpage' | 'centered'
* In a specific page, use the `Screen` component and change its props to change global page layout.
*/
export const Layout = ({ children }: { children: ReactNode }) => {
usePageDataAttribute()
const layoutSnap = useSnapshot(layoutStore)
const showHeader = layoutSnap.showHeader
const showFooter = layoutSnap.showFooter
+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.",
@@ -0,0 +1,57 @@
import { useEffect } from 'react'
import { matchRoute, useLocation, useRouter } from 'wouter'
import { proxy, useSnapshot } from 'valtio'
import { routes } from '@/routes'
const pageStore = proxy<{ override?: string }>({})
/**
* Apply a [data-page] attribute on the html element, that matches
* current route name, or a custom page name if provided.
*
* Used to allow easier customization of the app with custom CSS.
*
* This is meant to be used once without arguments in a wrapping component
* that renders the whole app. And, on a case by case basis with a `page` argument
* when needed.
*/
export const usePageDataAttribute = (page?: string) => {
const router = useRouter()
const [location] = useLocation()
const { override } = useSnapshot(pageStore)
useEffect(() => {
if (page === undefined) return
pageStore.override = page
return () => {
if (pageStore.override === page) {
pageStore.override = undefined
}
}
}, [page])
const routeName = Object.values(routes).find(
(route) => matchRoute(router.parser, route.path, location)[0]
)?.name
const pageName = page ?? override ?? routeName
useEffect(() => {
const root = document.documentElement
if (pageName) {
root.setAttribute('data-page', pageName)
} else {
root.removeAttribute('data-page')
}
}, [pageName])
useEffect(() => {
if (page !== undefined) return
return () => {
document.documentElement.removeAttribute('data-page')
}
}, [page])
}
+26 -5
View File
@@ -1,6 +1,12 @@
import { ComponentProps, forwardRef } from 'react'
import { cva } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx'
const boxDefaultVariants = {
variant: 'light',
size: 'default',
} as const
const box = cva({
base: {
position: 'relative',
@@ -64,10 +70,25 @@ const box = cva({
},
},
},
defaultVariants: {
variant: 'light',
size: 'default',
},
defaultVariants: boxDefaultVariants,
})
export const Box = styled('div', box)
const StyledBox = styled('div', box)
export const Box = forwardRef<HTMLDivElement, ComponentProps<typeof StyledBox>>(
({ className, ...props }, ref) => (
<StyledBox
ref={ref}
className={[
// These are not necessary for the app, but can help people using custom CSS for theming
'Box',
`Box--variant-${props.variant || boxDefaultVariants.variant}`,
`Box--size-${props.size || boxDefaultVariants.size}`,
props.type && `Box--type-${props.type}`,
className,
].filter(Boolean).join(' ')}
{...props}
/>
)
)
Box.displayName = 'Box'
+9 -2
View File
@@ -3,7 +3,7 @@ import {
type ButtonProps as RACButtonsProps,
} from 'react-aria-components'
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { buttonRecipe, buttonRecipeDefaultVariants, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode, forwardRef } from 'react'
import { Loader } from './Loader'
@@ -26,7 +26,14 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
ref={ref}
className={[buttonRecipe(variantProps), className].join(' ')}
className={[
buttonRecipe(variantProps),
className,
// These are not necessary for the app, but can help people using custom CSS for theming
'Button',
`Button--variant-${variantProps.variant || buttonRecipeDefaultVariants.variant}`,
`Button--size-${variantProps.size || buttonRecipeDefaultVariants.size}`,
].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
+9 -2
View File
@@ -3,7 +3,7 @@ import {
ToggleButton as RACToggleButton,
ToggleButtonProps as RACToggleButtonProps,
} from 'react-aria-components'
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
import { type ButtonRecipeProps, buttonRecipe, buttonRecipeDefaultVariants } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
export type ToggleButtonProps = RACToggleButtonProps &
@@ -25,7 +25,14 @@ export const ToggleButton = forwardRef<HTMLButtonElement, ToggleButtonProps>(
<RACToggleButton
ref={ref}
{...componentProps}
className={[buttonRecipe(variantProps), props.className].join(' ')}
className={[
buttonRecipe(variantProps),
props.className,
// These are not necessary for the app, but can help people using custom CSS for theming
'Button Button--toggle',
`Button--variant-${variantProps.variant || buttonRecipeDefaultVariants.variant}`,
`Button--size-${variantProps.size || buttonRecipeDefaultVariants.size}`,
].join(' ')}
>
<>
{componentProps.children as ReactNode}
@@ -114,10 +114,11 @@ const TooltipArrow = () => {
const Tooltip = ({
children,
className,
...props
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
return (
<StyledTooltip {...props}>
<StyledTooltip {...props} className={[className, 'Tooltip'].filter(Boolean).join(' ')}>
<TooltipArrow />
{children}
</StyledTooltip>
+7 -4
View File
@@ -1,5 +1,11 @@
import { type RecipeVariantProps, cva } from '@/styled-system/css'
export const buttonRecipeDefaultVariants = {
size: 'default',
variant: 'primary',
} as const
export const buttonRecipe = cva({
base: {
display: 'flex',
@@ -393,10 +399,7 @@ export const buttonRecipe = cva({
},
},
],
defaultVariants: {
size: 'default',
variant: 'primary',
},
defaultVariants: buttonRecipeDefaultVariants,
})
export type ButtonRecipe = typeof buttonRecipe