mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-19 19:21:47 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27b0f44b2a | |||
| 0b33806d52 | |||
| 0fa8e45b3c | |||
| dce67e37f8 |
@@ -13,6 +13,7 @@ and this project adheres to
|
||||
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
|
||||
- ✨(backend) add room soft-deletion to the external API
|
||||
- ✨(all) allow any authenticated user to start a recording
|
||||
- ✨(backend) authenticate external API calls with Menshen exchanged tokens
|
||||
|
||||
## [1.35.0] - 2026-10-13
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+15
-1
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user