mirror of
https://github.com/suitenumerique/meet.git
synced 2026-11-14 22:18:33 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06c4e277db | |||
| b6b3ce28db | |||
| f27f2fb7de | |||
| 061025b928 | |||
| 100044eeaa | |||
| e158b4faaf | |||
| 3a1f0a342d | |||
| 62571cd9fc | |||
| 8fccf46429 | |||
| 9481350383 | |||
| e9a58d270b | |||
| e3351075aa | |||
| 68198e9961 | |||
| e3140e0300 |
+5
-6
@@ -12,10 +12,7 @@ and this project adheres to
|
||||
|
||||
- 🌐(i18n) add Portuguese language support
|
||||
- ✨(frontend) add jittered exponential backoff to LiveKit reconnections
|
||||
- ✨(backend) add OIDC_ALLOW_DUPLICATE_EMAILS to let users share an email
|
||||
- ✨(frontend) tell participants when a recording is interrupted
|
||||
- ✨(backend) show the last login of users in the admin
|
||||
- ✨(backend) filter users sharing an email in the admin
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -30,9 +27,9 @@ and this project adheres to
|
||||
- 🔒️(frontend) send the display name in the join request body
|
||||
- 🔥(backend) drop is_administrable from unregistered room payloads
|
||||
- 🍱(frontend) add Portuguese glyphs to Marianne subsetted fonts
|
||||
- 💥(backend) refuse new users whose email is already used
|
||||
- ♻️(backend) match emails in a single normalized form
|
||||
- ⚡️(backend) index user emails for exact lookups
|
||||
- 🚸(frontend) bring new speakers to the top of the carousel
|
||||
- 🚸(frontend) bring raised hands into view in the carousel
|
||||
- ⚡️(frontend) serve illustrations as WebP with a PNG fallback
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -48,6 +45,8 @@ and this project adheres to
|
||||
- 🔒️(docker) move frontend to nginx/nginx-unprivileged image
|
||||
- 🐛(frontend) accept meeting links with query params in join dialog
|
||||
- 🐛(backend) allow muting when everyone_can_mute is stored as null #1942
|
||||
- 🐛(frontend) apply the sans font token on every page
|
||||
- 🐛(frontend) promote the active speaker in the carousel layout
|
||||
|
||||
## [1.37.0] - 2026-11-01
|
||||
|
||||
|
||||
@@ -45,15 +45,6 @@ parse as a UUID, yet to normalize to an existing room's id, matched no registere
|
||||
unregistered one. The token minted for that unregistered room was valid for the registered room: anyone could join a
|
||||
trusted or restricted room without being admitted through the lobby.
|
||||
|
||||
### User emails matched in a single normalized form
|
||||
|
||||
User emails are now stored stripped, with their ASCII letters lowercased, and compared exactly. A migration normalizes
|
||||
the stored emails and stores blank ones as NULL. Other letters keep their case.
|
||||
|
||||
- With `OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION` disabled (the default), a new `sub` whose email is already used by
|
||||
another user is now refused, by the OIDC login and by external API calls made with a Menshen token, instead of getting
|
||||
a second account. To keep creating users with a duplicate email, set the new `OIDC_ALLOW_DUPLICATE_EMAILS=True`.
|
||||
|
||||
## v1.37.0
|
||||
|
||||
### Crisp support chat replaced by the La Suite support form
|
||||
|
||||
@@ -33,7 +33,7 @@ server {
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|webp|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ server {
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|webp|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ is told by `event.outcome`, `lasuite.outcome` and `event.reason`, never by the n
|
||||
| `event.action` | Emitted when | Notable fields |
|
||||
|---|---|---|
|
||||
| `application.token.issue` | An application asks for a delegated token (`POST /external-api/v1.0/application/token/`), whether it gets one or is refused. It can be refused for bad credentials, an inactive application, an invalid or unauthorized email domain, an unknown user, or a provisioning conflict | On success: `user.*` is the delegated user, `entity.target` the application, and `lasuite.details` holds `scopes`, `user_provisioned` and `expires_in`. On refusal: `event.reason`, `http.response.status_code` and `lasuite.details.requested_domain`. Until its credentials are verified, the client id the caller submitted is only recorded as `lasuite.details.claimed_client_id`: it never sets `lasuite.application` or `organization` |
|
||||
| `user.provision` | An application creates a provisional user from an email address, or fails to. It is a `failure` with reason `conflict` when a concurrent request created the user first. Also emitted when a La Suite application calls the external API, through the resource server or with a Menshen token, for a `sub` no account has and `OIDC_CREATE_USER` lets the backend create one. An OIDC sign-in refused because the email of its unknown `sub` is already used, with `OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION` disabled, or shared by several active users, with it enabled, is recorded as a `failure` with reason `conflict` | `user.target` and `entity.target` are the user, the existing one on a conflict. `lasuite.auth.method` is `client_credentials`, `resource_server`, `menshen_token` or `oidc`. A provisional user has no `sub` yet: its id links this event to the winning one and to its later `user.login`. On a conflict over an email, `lasuite.details.requested_sub` is the `sub` no account was created for |
|
||||
| `user.provision` | An application creates a provisional user from an email address, or fails to. It is a `failure` with reason `conflict` when a concurrent request created the user first. Also emitted when a La Suite application calls the external API, through the resource server or with a Menshen token, for a `sub` no account has and `OIDC_CREATE_USER` lets the backend create one | `user.target` and `entity.target` are the user, the existing one on a conflict. `lasuite.auth.method` is `client_credentials`, `resource_server` or `menshen_token`. A provisional user has no `sub` yet: its id links this event to the winning one and to its later `user.login`. On a conflict over an email, `lasuite.details.requested_sub` is the `sub` no account was created for |
|
||||
| `transit_code.issue` | An application mints a single-use transit code for its delegated user | `entity.target` is the code, whose `id` is its SHA-256. `user.target` is the user it is minted for. `lasuite.details.expires_in` |
|
||||
| `transit_code.exchange` | An embedded frontend exchanges a transit code for a user access token | `event.category` is `authentication`, `lasuite.auth.method` is `transit_code`, and `entity.target` is the code's SHA-256. Once the code verifies, even if the exchange is then refused: `lasuite.actor.type` is `application` (the one that minted it), and `user.target` the user it was minted for. On success, `user.*` is that user too, with `lasuite.details.expires_in`. A code that does not verify is recorded as an `anonymous` failed authentication |
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ Authentication relies on [django-lasuite](https://github.com/suitenumerique/djan
|
||||
| OIDC_VERIFY_SSL | Verify SSL certificates when contacting the OIDC provider | `true` |
|
||||
| OIDC_USE_NONCE | Use `nonce` to prevent replay attacks | `true` |
|
||||
| OIDC_STORE_ID_TOKEN | Store the ID token returned by the OIDC provider (useful for backend validation) | `true` |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to identifying users by email if `sub` claim does not match. Cannot be enabled together with `OIDC_ALLOW_DUPLICATE_EMAILS`. | `false` |
|
||||
| OIDC_ALLOW_DUPLICATE_EMAILS | When the email fallback is disabled, create a new user for an unknown `sub` even if its email is already used by another user. Otherwise, the login is refused. Emails are compared once stripped, with their ASCII letters lowercased. | `false` |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to identifying users by email if `sub` claim does not match. Enable only if emails are unique. | `false` |
|
||||
| **Endpoints** | | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | URL to retrieve JSON Web Key Sets (for token verification) | — |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | URL for authorization requests | — |
|
||||
|
||||
@@ -379,8 +379,7 @@ These are the environmental options available on meet backend.
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
|
||||
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
|
||||
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification, cannot be enabled with OIDC_ALLOW_DUPLICATE_EMAILS | false |
|
||||
| OIDC_ALLOW_DUPLICATE_EMAILS | Without the email fallback, create a user for a new sub whose email is already used | false |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
|
||||
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
|
||||
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
|
||||
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
|
||||
|
||||
@@ -4,7 +4,6 @@ from django import forms
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.db import transaction
|
||||
from django.db.models import Exists, OuterRef
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -47,26 +46,6 @@ class FileInline(admin.TabularInline):
|
||||
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
|
||||
|
||||
|
||||
class DuplicateEmailFilter(admin.SimpleListFilter):
|
||||
"""Filter the users whose stored email is shared with another user."""
|
||||
|
||||
title = _("duplicate email")
|
||||
parameter_name = "duplicate_email"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
"""Return the filter options."""
|
||||
return [("yes", _("Yes"))]
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
"""Keep the users sharing their email with another user."""
|
||||
if self.value() != "yes":
|
||||
return queryset
|
||||
others = models.User.objects.filter(email=OuterRef("email")).exclude(
|
||||
pk=OuterRef("pk")
|
||||
)
|
||||
return queryset.filter(Exists(others))
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
"""Admin class for the User model"""
|
||||
@@ -110,10 +89,7 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Important dates"),
|
||||
{"fields": ("created_at", "updated_at", "last_login")},
|
||||
),
|
||||
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(
|
||||
@@ -136,16 +112,9 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"is_superuser",
|
||||
"is_device",
|
||||
"created_at",
|
||||
"last_login",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = (
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"is_device",
|
||||
"is_active",
|
||||
DuplicateEmailFilter,
|
||||
)
|
||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||
ordering = (
|
||||
"is_active",
|
||||
"-is_superuser",
|
||||
@@ -162,16 +131,9 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"short_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"last_login",
|
||||
)
|
||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||
|
||||
def get_ordering(self, request):
|
||||
"""Group the users sharing an email when filtering on duplicates."""
|
||||
if DuplicateEmailFilter.parameter_name in request.GET:
|
||||
return ("email", "created_at")
|
||||
return super().get_ordering(request)
|
||||
|
||||
|
||||
@admin.register(models.File)
|
||||
class FileAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import (
|
||||
SuspiciousOperation,
|
||||
@@ -15,13 +13,9 @@ from lasuite.oidc_login.backends import (
|
||||
)
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
|
||||
from core import audit, auditing
|
||||
from core.models import DuplicateEmailError, User
|
||||
from core.utils import normalize_email
|
||||
from core.models import User
|
||||
from core.validators import sub_validator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
@@ -89,25 +83,18 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
) from err
|
||||
|
||||
try:
|
||||
return User.objects.get_user_by_sub_or_email(sub, email)
|
||||
except DuplicateEmailError as err:
|
||||
logger.warning("Email already used, not creating user: %s", sub)
|
||||
existing_user = (
|
||||
User.objects.filter(email=normalize_email(email))
|
||||
.order_by("-is_active", "-created_at")
|
||||
.first()
|
||||
)
|
||||
audit.log(
|
||||
auditing.USER_PROVISION,
|
||||
outcome=audit.Outcome.FAILURE,
|
||||
reason=audit.Reason.CONFLICT,
|
||||
actor=None,
|
||||
actor_type=audit.ActorType.ANONYMOUS,
|
||||
auth_method="oidc",
|
||||
target=existing_user,
|
||||
requested_sub=sub,
|
||||
)
|
||||
raise SuspiciousOperation(err.message) from err
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email__iexact=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
except User.MultipleObjectsReturned as e:
|
||||
raise SuspiciousOperation(
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
return None
|
||||
|
||||
|
||||
class SessionAuthenticationWith401(SessionAuthentication):
|
||||
|
||||
@@ -20,7 +20,6 @@ from rest_framework import authentication, exceptions
|
||||
from core import audit, auditing
|
||||
from core.models import Application, ApplicationScope
|
||||
from core.services import jwt_token
|
||||
from core.utils import normalize_email
|
||||
from core.validators import sub_validator
|
||||
|
||||
User = get_user_model()
|
||||
@@ -64,10 +63,6 @@ def get_or_create_user_by_sub(sub, email=None, *, auth_method, client_id=None):
|
||||
Shared by the resource server backends, so that tokens introspected with the
|
||||
OIDC provider or with Menshen resolve users the same way.
|
||||
|
||||
Users are never identified by email here, but a user to create is refused
|
||||
when its normalized email is already stored, unless
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS is set.
|
||||
|
||||
Args:
|
||||
sub: Subject identifier from the introspected token
|
||||
email: Email to store on a newly created user
|
||||
@@ -95,10 +90,12 @@ def get_or_create_user_by_sub(sub, email=None, *, auth_method, client_id=None):
|
||||
if not settings.OIDC_CREATE_USER:
|
||||
return None
|
||||
|
||||
normalized_email = normalize_email(email)
|
||||
# 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.
|
||||
existing_user = (
|
||||
User.objects.filter(email=normalized_email).first()
|
||||
if normalized_email and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
User.objects.filter(email__iexact=email).first()
|
||||
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and email
|
||||
else None
|
||||
)
|
||||
if existing_user is not None:
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
|
||||
|
||||
@@ -20,15 +21,14 @@ ROLE_PRIORITY = {
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Merge duplicate users sharing the same email into the most recently
|
||||
created one.
|
||||
Merge duplicate users sharing the same email (case-insensitive) into the
|
||||
most recently created one.
|
||||
|
||||
Emails are stored normalized (stripped, ASCII letters lowercased) and are
|
||||
compared exactly, so 'John@Example.com' and 'john@example.com' are both
|
||||
stored as 'john@example.com' and treated as duplicates. The KEPT user is
|
||||
the most recently created. All room memberships, recording accesses and
|
||||
files are transferred to it. When a conflict exists, the higher-privilege
|
||||
role wins. Stale users are then deleted.
|
||||
Emails are compared case-insensitively, so 'John@Example.com' and
|
||||
'john@example.com' are treated as duplicates. The KEPT user is the most
|
||||
recently created. All room memberships, recording accesses and files are
|
||||
transferred to it. When a conflict exists, the higher-privilege role wins.
|
||||
Stale users are then deleted.
|
||||
Each email group is processed inside a single database transaction.
|
||||
"""
|
||||
|
||||
@@ -60,13 +60,16 @@ class Command(BaseCommand):
|
||||
users_qs = users_qs.filter(email__icontains=email_filter)
|
||||
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
|
||||
|
||||
# Group emails case-insensitively so 'John@X.com' and 'john@x.com'
|
||||
# are detected as duplicates of each other.
|
||||
duplicate_emails = (
|
||||
users_qs.exclude(email__isnull=True)
|
||||
.exclude(email="")
|
||||
.values("email")
|
||||
.annotate(email_lower=Lower("email"))
|
||||
.values("email_lower")
|
||||
.annotate(cnt=Count("id"))
|
||||
.filter(cnt__gt=1)
|
||||
.values_list("email", flat=True)
|
||||
.values_list("email_lower", flat=True)
|
||||
)
|
||||
|
||||
if not duplicate_emails:
|
||||
@@ -82,9 +85,12 @@ class Command(BaseCommand):
|
||||
failed_emails = []
|
||||
|
||||
for email in duplicate_emails:
|
||||
# Case-insensitive lookup to fetch every casing variant of the email.
|
||||
# Secondary sort by id ensures a stable, deterministic order when
|
||||
# created_at timestamps are equal (common in tests and bulk imports).
|
||||
users = list(User.objects.filter(email=email).order_by("created_at", "id"))
|
||||
users = list(
|
||||
User.objects.filter(email__iexact=email).order_by("created_at", "id")
|
||||
)
|
||||
kept_user = users[-1]
|
||||
stale_users = users[:-1]
|
||||
|
||||
@@ -124,6 +130,10 @@ class Command(BaseCommand):
|
||||
failed_emails.append(email)
|
||||
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
|
||||
|
||||
if not kept_user.email.islower():
|
||||
kept_user.email = kept_user.email.lower()
|
||||
kept_user.save(update_fields=["email"])
|
||||
|
||||
if failed_emails:
|
||||
raise CommandError(
|
||||
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Store user emails stripped and ASCII-lowercased, and blank ones as NULL."""
|
||||
|
||||
import string
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
BATCH_SIZE = 500
|
||||
|
||||
_ASCII_LOWERCASE_TABLE = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
|
||||
|
||||
|
||||
def normalize_email(email):
|
||||
"""Strip an email and lowercase its ASCII letters only."""
|
||||
return (email or "").strip().translate(_ASCII_LOWERCASE_TABLE)
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
"""Normalize the user emails, writing the changed rows in batches."""
|
||||
User = apps.get_model("core", "User")
|
||||
batch = []
|
||||
for user_id, email in User.objects.values_list("id", "email").iterator(
|
||||
chunk_size=BATCH_SIZE
|
||||
):
|
||||
normalized = normalize_email(email) or None
|
||||
if normalized == email:
|
||||
continue
|
||||
batch.append(User(id=user_id, email=normalized))
|
||||
if len(batch) == BATCH_SIZE:
|
||||
User.objects.bulk_update(batch, ["email"])
|
||||
batch = []
|
||||
if batch:
|
||||
User.objects.bulk_update(batch, ["email"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("core", "0029_alter_application_client_secret_sha256"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, migrations.RunPython.noop, elidable=True),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Generated by Django 5.2.17 on 2026-11-10 16:28
|
||||
|
||||
import core.models
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0030_normalize_user_emails'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='user',
|
||||
managers=[
|
||||
('objects', core.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
# Generated by Django 5.2.17 on 2026-11-10 17:00
|
||||
|
||||
from django.contrib.postgres.operations import (
|
||||
AddIndexConcurrently,
|
||||
RemoveIndexConcurrently,
|
||||
)
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('core', '0031_alter_user_managers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name="user",
|
||||
index=models.Index(fields=["email"], name="user_email_idx"),
|
||||
),
|
||||
RemoveIndexConcurrently(
|
||||
model_name="user",
|
||||
name="user_email_upper_idx",
|
||||
),
|
||||
]
|
||||
@@ -115,16 +115,6 @@ class RoomAccessLevel(models.TextChoices):
|
||||
RESTRICTED = "restricted", _("Restricted Access")
|
||||
|
||||
|
||||
class DuplicateEmailError(Exception):
|
||||
"""Raised when an email is already associated with a pre-existing user."""
|
||||
|
||||
def __init__(self, message=None, email=None):
|
||||
"""Set message and email to describe the exception."""
|
||||
self.message = message
|
||||
self.email = email
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
@@ -162,36 +152,6 @@ class BaseModel(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserManager(auth_models.UserManager):
|
||||
"""Custom manager for User model with additional methods."""
|
||||
|
||||
def get_user_by_sub_or_email(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return self.get(sub=sub)
|
||||
except self.model.DoesNotExist as err:
|
||||
normalized_email = utils.normalize_email(email)
|
||||
if not normalized_email:
|
||||
return None
|
||||
|
||||
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
users = self.filter(email=normalized_email).order_by("-is_active")[:2]
|
||||
if len(users) == 2 and users[1].is_active:
|
||||
raise DuplicateEmailError(
|
||||
"Several active users are associated with this email."
|
||||
) from err
|
||||
return users[0] if users else None
|
||||
if (
|
||||
not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
and self.filter(email=normalized_email).exists()
|
||||
):
|
||||
raise DuplicateEmailError(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
"associated with a registered user."
|
||||
) from err
|
||||
return None
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
@@ -269,7 +229,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
),
|
||||
)
|
||||
|
||||
objects = UserManager()
|
||||
objects = auth_models.UserManager()
|
||||
|
||||
USERNAME_FIELD = "admin_email"
|
||||
REQUIRED_FIELDS = []
|
||||
@@ -287,21 +247,12 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["email"], name="user_email_idx"),
|
||||
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.admin_email or str(self.id)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Store the email normalized, and a blank one as NULL, even on a partial save."""
|
||||
email = utils.normalize_email(self.email) or None
|
||||
update_fields = kwargs.get("update_fields")
|
||||
if update_fields is not None and email != self.email:
|
||||
kwargs["update_fields"] = {*update_fields, "email"}
|
||||
self.email = email
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Email this user."""
|
||||
if not self.email:
|
||||
|
||||
@@ -5,7 +5,6 @@ from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
from core import audit, auditing, models
|
||||
from core.utils import normalize_email
|
||||
|
||||
|
||||
class ProvisionalUserError(Exception):
|
||||
@@ -43,7 +42,7 @@ class ProvisionalUserService:
|
||||
def _get_by_email(self, email: str) -> models.User | None:
|
||||
"""Return the user with this email, or None if not found."""
|
||||
try:
|
||||
return models.User.objects.get(email=normalize_email(email))
|
||||
return models.User.objects.get(email__iexact=email)
|
||||
except models.User.DoesNotExist:
|
||||
return None
|
||||
except models.User.MultipleObjectsReturned as e:
|
||||
|
||||
@@ -8,7 +8,6 @@ import pytest
|
||||
from lasuite.marketing.tasks import create_or_update_contact
|
||||
|
||||
from core import models
|
||||
from core.audit.testing import find_events
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.factories import UserFactory
|
||||
|
||||
@@ -297,26 +296,17 @@ def test_finds_user_by_sub(django_assert_num_queries):
|
||||
|
||||
|
||||
def test_finds_user_when_email_fallback_disabled(django_assert_num_queries, settings):
|
||||
"""Should refuse a sub that is not found when its email is already used, and email
|
||||
fallback is disabled."""
|
||||
"""Should not return a user when not found by sub, and email fallback is disabled."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
with (
|
||||
django_assert_num_queries(3),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match=(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
"associated with a registered user."
|
||||
),
|
||||
),
|
||||
):
|
||||
klass.get_existing_user("wrong-sub", db_user.email)
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_existing_user("wrong-sub", db_user.email)
|
||||
|
||||
assert user is None
|
||||
|
||||
|
||||
def test_finds_user_when_email_is_none(django_assert_num_queries, settings):
|
||||
@@ -350,7 +340,7 @@ def test_finds_user_by_email(django_assert_num_queries, settings):
|
||||
|
||||
|
||||
def test_finds_user_case_insensitive_email(django_assert_num_queries, settings):
|
||||
"""Should match email ignoring ASCII case when falling back to email."""
|
||||
"""Should match email case-insensitively when falling back to email."""
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
@@ -363,7 +353,7 @@ def test_finds_user_case_insensitive_email(django_assert_num_queries, settings):
|
||||
|
||||
|
||||
def test_finds_user_multiple_users_same_email(django_assert_num_queries, settings):
|
||||
"""Should refuse to pick one of several active users sharing the email."""
|
||||
"""Should handle multiple users with same email appropriately."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
@@ -373,278 +363,30 @@ def test_finds_user_multiple_users_same_email(django_assert_num_queries, setting
|
||||
UserFactory(email=email) # Second user with same email
|
||||
|
||||
with (
|
||||
django_assert_num_queries(3),
|
||||
django_assert_num_queries(2),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="Several active users are associated with this email.",
|
||||
match="Multiple user accounts share a common email.",
|
||||
),
|
||||
):
|
||||
klass.get_existing_user("wrong-sub", email)
|
||||
|
||||
|
||||
def test_finds_user_whitespace_email(django_assert_num_queries, settings):
|
||||
"""Should match emails with surrounding whitespace once stripped."""
|
||||
"""Should not match emails with whitespace."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_existing_user("wrong-sub", " foo@mail.com ")
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fallback", [True, False])
|
||||
def test_finds_user_whitespace_only_email(
|
||||
django_assert_num_queries, settings, fallback
|
||||
):
|
||||
"""Should not look a user up by email when the email is empty once stripped, even
|
||||
if a legacy user is stored with an empty email."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = fallback
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
legacy_user = UserFactory()
|
||||
models.User.objects.filter(pk=legacy_user.pk).update(email="")
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_existing_user("wrong-sub", " ")
|
||||
|
||||
assert user is None
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_via_email_case_insensitive(
|
||||
monkeypatch, settings
|
||||
):
|
||||
"""
|
||||
If an existing user doesn't match the sub but matches the email with another
|
||||
ASCII case, the user should be returned and its email should stay normalized.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
db_user.refresh_from_db()
|
||||
assert db_user.email == "john.doe@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email",
|
||||
[
|
||||
"dısk@example.com",
|
||||
"diſk@example.com",
|
||||
"disK@example.com",
|
||||
],
|
||||
ids=["dotless-i", "long-s", "kelvin-sign"],
|
||||
)
|
||||
@pytest.mark.parametrize("fallback", [True, False])
|
||||
def test_authentication_getter_existing_user_via_email_non_ascii(
|
||||
monkeypatch, settings, fallback, email
|
||||
):
|
||||
"""
|
||||
If an existing user doesn't match the sub and the email only matches its email
|
||||
through Unicode case folding, a new user should be created and the existing user
|
||||
should be left untouched.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = fallback
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="disk@example.com")
|
||||
db_sub = db_user.sub
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "other-sub", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user != db_user
|
||||
assert user.sub == "other-sub"
|
||||
assert user.email == email
|
||||
assert models.User.objects.count() == 2
|
||||
db_user.refresh_from_db()
|
||||
assert db_user.sub == db_sub
|
||||
assert db_user.email == "disk@example.com"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_inactive_user_via_email(monkeypatch, settings):
|
||||
"""
|
||||
If the only user matching the email is inactive, the login should be refused
|
||||
instead of creating another user.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
UserFactory(email="john.doe@example.com", is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": "John.Doe@example.com"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(SuspiciousOperation, match="User account is disabled"):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_no_fallback_to_email_no_duplicate(monkeypatch, settings):
|
||||
"""
|
||||
When the email fallback is disabled, an unknown sub whose email is already used,
|
||||
in another ASCII case, should be refused instead of creating a second user.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match=(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
"associated with a registered user."
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.count() == 1
|
||||
db_user.refresh_from_db()
|
||||
assert db_user.sub != "123"
|
||||
|
||||
|
||||
def test_authentication_getter_no_fallback_to_email_no_duplicate_is_audited(
|
||||
audit_events, caplog, monkeypatch, settings
|
||||
):
|
||||
"""
|
||||
A login refused because its email is already used should log the refused sub, and
|
||||
be audited as a conflict with the user holding the email, never naming the email.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "new-sub", "email": "JOHN.DOE@EXAMPLE.COM"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(SuspiciousOperation):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert "Email already used, not creating user: new-sub" in caplog.text
|
||||
assert "john.doe@example.com" not in caplog.text.lower()
|
||||
|
||||
[provision] = find_events(audit_events, "user.provision")
|
||||
|
||||
assert provision["event"]["outcome"] == "failure"
|
||||
assert provision["event"]["reason"] == "conflict"
|
||||
assert provision["lasuite"]["actor"] == {"type": "anonymous"}
|
||||
assert provision["lasuite"]["auth"] == {"method": "oidc"}
|
||||
assert provision["user"] == {
|
||||
"target": {"id": str(db_user.pk), "domain": "example.com"}
|
||||
}
|
||||
assert provision["entity"]["target"]["id"] == str(db_user.pk)
|
||||
assert provision["entity"]["target"]["raw"] == {"sub": db_user.sub}
|
||||
assert provision["lasuite"]["details"] == {"requested_sub": "new-sub"}
|
||||
assert provision["log"]["level"] == "warning"
|
||||
assert "john.doe@example.com" not in str(provision).lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fallback", [True, False])
|
||||
def test_authentication_getter_email_conflict_audits_active_user(
|
||||
audit_events, fallback, monkeypatch, settings
|
||||
):
|
||||
"""
|
||||
A login refused because of its email, with or without the email fallback, should
|
||||
be audited against the newest active user holding the email, not a newer inactive
|
||||
one.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = fallback
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
UserFactory(email="john.doe@example.com")
|
||||
active_user = UserFactory(email="john.doe@example.com")
|
||||
UserFactory(email="john.doe@example.com", is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "new-sub", "email": "john.doe@example.com"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(SuspiciousOperation):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
[provision] = find_events(audit_events, "user.provision")
|
||||
|
||||
assert provision["event"]["reason"] == "conflict"
|
||||
assert provision["user"]["target"]["id"] == str(active_user.pk)
|
||||
assert provision["lasuite"]["details"] == {"requested_sub": "new-sub"}
|
||||
assert models.User.objects.count() == 3
|
||||
|
||||
|
||||
def test_authentication_getter_no_fallback_to_email_allow_duplicate(
|
||||
monkeypatch, settings
|
||||
):
|
||||
"""
|
||||
When the email fallback is disabled and duplicate emails are allowed, an unknown
|
||||
sub whose email is already used should get a new user.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user != db_user
|
||||
assert user.sub == "123"
|
||||
assert user.email == "john.doe@example.com"
|
||||
assert models.User.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email",
|
||||
[
|
||||
|
||||
@@ -19,13 +19,6 @@ pytestmark = pytest.mark.django_db
|
||||
# pylint: disable=W0613
|
||||
|
||||
|
||||
def _user_with_stored_email(email):
|
||||
"""Create a user storing the email as is, like the rows predating the normalization."""
|
||||
user = UserFactory()
|
||||
User.objects.filter(pk=user.pk).update(email=email)
|
||||
return user
|
||||
|
||||
|
||||
def test_merge_no_duplicates_does_nothing():
|
||||
"""Command should do nothing when no duplicate users exist."""
|
||||
user = UserFactory(email="unique@example.com")
|
||||
@@ -43,30 +36,24 @@ def test_merge_keeps_most_recently_created_user():
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"lookalike_email",
|
||||
[
|
||||
"dısk@example.com",
|
||||
"diſk@example.com",
|
||||
"disK@example.com",
|
||||
],
|
||||
ids=["dotless-i", "long-s", "kelvin-sign"],
|
||||
)
|
||||
def test_merge_does_not_merge_lookalike_emails(lookalike_email):
|
||||
"""A user whose email only matches the duplicates' email through Unicode case
|
||||
folding should not be merged with them, even when created last."""
|
||||
user1 = UserFactory(email="disk@example.com")
|
||||
user2 = UserFactory(email="disk@example.com")
|
||||
ra = UserResourceAccessFactory(user=user1)
|
||||
lookalike = UserFactory(email=lookalike_email)
|
||||
def test_merge_user_case_insensitive():
|
||||
"""Emails differing only by case should be treated as duplicates and merged,
|
||||
keeping the most recently created user."""
|
||||
user1 = UserFactory(email="Dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
call_command("merge_duplicate_users")
|
||||
|
||||
assert not User.objects.filter(id=user1.id).exists()
|
||||
ra.refresh_from_db()
|
||||
assert ra.user == user2
|
||||
lookalike.refresh_from_db()
|
||||
assert lookalike.email == lookalike_email
|
||||
assert not ResourceAccess.objects.filter(user=lookalike).exists()
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
|
||||
user3 = UserFactory(email="joe@example.com")
|
||||
user4 = UserFactory(email="Joe@example.com")
|
||||
call_command("merge_duplicate_users")
|
||||
assert not User.objects.filter(id=user3.id).exists()
|
||||
assert User.objects.filter(id=user4.id).exists()
|
||||
|
||||
user4.refresh_from_db()
|
||||
assert user4.email.islower()
|
||||
|
||||
|
||||
def test_merge_deletes_all_stale_users():
|
||||
@@ -422,11 +409,10 @@ def test_merge_does_not_merge_users_with_null_email():
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
|
||||
|
||||
def test_merge_does_not_merge_users_with_legacy_blank_email():
|
||||
"""Users with a legacy empty-string email must never be merged together, even if
|
||||
multiple exist."""
|
||||
user1 = _user_with_stored_email("")
|
||||
user2 = _user_with_stored_email("")
|
||||
def test_merge_does_not_merge_users_with_blank_email():
|
||||
"""Users with empty-string email must never be merged together, even if multiple exist."""
|
||||
user1 = UserFactory(email="")
|
||||
user2 = UserFactory(email="")
|
||||
call_command("merge_duplicate_users")
|
||||
assert User.objects.filter(id=user1.id).exists()
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
@@ -488,7 +474,7 @@ def test_merge_email_filter_no_match_does_nothing():
|
||||
|
||||
def test_merge_email_filter_is_case_insensitive():
|
||||
"""Command should match emails case-insensitively when filtering."""
|
||||
UserFactory(email="user1@example.com")
|
||||
UserFactory(email="user1@example.com")
|
||||
call_command("merge_duplicate_users", email_filter="@Example.COM")
|
||||
UserFactory(email="user1@Example.com")
|
||||
UserFactory(email="user1@Example.com")
|
||||
call_command("merge_duplicate_users", email_filter="@example.com")
|
||||
assert User.objects.filter(email="user1@example.com").count() == 1
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.db import IntegrityError
|
||||
|
||||
import pytest
|
||||
@@ -39,20 +38,6 @@ def test_get_or_create_existing_user_is_not_audited(audit_events):
|
||||
assert not find_events(audit_events, "user.provision")
|
||||
|
||||
|
||||
def test_get_or_create_several_users_same_normalized_email(audit_events):
|
||||
"""Several users sharing the normalized email should be refused, not picked."""
|
||||
UserFactory(email="john.doe@example.com")
|
||||
UserFactory(email="John.Doe@example.com")
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation, match="Multiple user accounts share a common email."
|
||||
):
|
||||
ProvisionalUserService().get_or_create(" JOHN.DOE@example.com", "my-client")
|
||||
|
||||
assert User.objects.count() == 2
|
||||
assert not find_events(audit_events, "user.provision")
|
||||
|
||||
|
||||
def test_get_or_create_creation_is_audited(audit_events):
|
||||
"""A created user is identified by its id, without sub nor email."""
|
||||
user, created = ProvisionalUserService().get_or_create(
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Tests for the user admin class."""
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def static_storage_without_manifest(settings):
|
||||
"""Render the admin pages without collecting the static files first."""
|
||||
settings.STORAGES = {
|
||||
**settings.STORAGES,
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _admin_client(client):
|
||||
"""Log a superuser in the client."""
|
||||
client.force_login(factories.UserFactory(is_staff=True, is_superuser=True))
|
||||
return client
|
||||
|
||||
|
||||
def test_admin_users_duplicate_email_filter(client):
|
||||
"""The filter should keep the users sharing their stored email, grouped by email
|
||||
and ordered by creation, but not the users whose email only matches through
|
||||
Unicode case folding."""
|
||||
first_jim = factories.UserFactory(email="jim@example.com")
|
||||
first_ann = factories.UserFactory(email="ann@example.com")
|
||||
second_jim = factories.UserFactory(email="JIM@example.com")
|
||||
second_ann = factories.UserFactory(email="ann@example.com")
|
||||
factories.UserFactory(email="jım@example.com")
|
||||
factories.UserFactory(email="bob@example.com")
|
||||
factories.UserFactory(email=None)
|
||||
factories.UserFactory(email=None)
|
||||
|
||||
response = _admin_client(client).get("/admin/core/user/?duplicate_email=yes")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert [user.pk for user in response.context["cl"].result_list] == [
|
||||
first_ann.pk,
|
||||
second_ann.pk,
|
||||
first_jim.pk,
|
||||
second_jim.pk,
|
||||
]
|
||||
|
||||
|
||||
def test_admin_users_last_login(client):
|
||||
"""The last login of users should be shown in the list and on the change page."""
|
||||
user = factories.UserFactory()
|
||||
admin_client = _admin_client(client)
|
||||
|
||||
changelist = admin_client.get("/admin/core/user/")
|
||||
change = admin_client.get(f"/admin/core/user/{user.pk}/change/")
|
||||
|
||||
assert changelist.status_code == 200
|
||||
assert "column-last_login" in changelist.content.decode()
|
||||
assert change.status_code == 200
|
||||
assert "field-last_login" in change.content.decode()
|
||||
@@ -280,22 +280,19 @@ def test_menshen_unknown_user_not_created(settings):
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("fallback", [True, False])
|
||||
@pytest.mark.parametrize("existing_sub", [None, "other-sub"])
|
||||
def test_menshen_unknown_user_email_already_used(settings, existing_sub, fallback):
|
||||
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, whether
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION is set or not."""
|
||||
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 = fallback
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
existing_user = UserFactory(sub=existing_sub, email="user@example.com")
|
||||
existing_user = UserFactory(sub=existing_sub, email="User@Example.com")
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json=_introspection("new-sub", email=" User@Example.COM "),
|
||||
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
@@ -308,43 +305,6 @@ def test_menshen_unknown_user_email_already_used(settings, existing_sub, fallbac
|
||||
assert not Room.objects.exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize("existing_sub", [None, "other-sub"])
|
||||
def test_menshen_unknown_user_email_already_used_allowed(
|
||||
audit_events, settings, existing_sub
|
||||
):
|
||||
"""An unknown sub should get its own account, even with an email already used,
|
||||
when OIDC_ALLOW_DUPLICATE_EMAILS=True."""
|
||||
|
||||
settings.OIDC_CREATE_USER = True
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
|
||||
|
||||
existing_user = UserFactory(sub=existing_sub, email="user@example.com")
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json=_introspection("new-sub", email=" User@Example.COM "),
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
assert response.status_code == 201
|
||||
assert User.objects.count() == 2
|
||||
user = User.objects.get(sub="new-sub")
|
||||
assert user != existing_user
|
||||
assert user.email == "user@example.com"
|
||||
existing_user.refresh_from_db()
|
||||
assert existing_user.sub == existing_sub
|
||||
|
||||
[provision] = find_events(audit_events, "user.provision")
|
||||
|
||||
assert provision["event"]["outcome"] == "success"
|
||||
assert provision["lasuite"]["outcome"] == "success"
|
||||
assert provision["entity"]["target"]["id"] == str(user.pk)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_menshen_is_audited_as_application(audit_events):
|
||||
"""A call with a Menshen token is made by the client Menshen reports, acting for
|
||||
@@ -422,12 +382,10 @@ def test_menshen_unknown_user_email_already_used_is_audited(audit_events, settin
|
||||
settings.OIDC_CREATE_USER = True
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
existing_user = UserFactory(sub=None, email="user@example.com")
|
||||
existing_user = UserFactory(sub=None, email="User@Example.com")
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
INTROSPECTION_ENDPOINT,
|
||||
json=_introspection("new-sub", email=" User@Example.COM "),
|
||||
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
|
||||
)
|
||||
|
||||
response = _create_room()
|
||||
|
||||
@@ -4,12 +4,11 @@ Unit tests for the User model
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import update_last_login
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -118,13 +117,12 @@ def test_models_users_email_unique_when_sub_is_null():
|
||||
|
||||
|
||||
def test_models_users_email_unique_case_insensitive_when_sub_is_null():
|
||||
"""Email uniqueness among users with no sub (pending users) should also ignore the
|
||||
case of non-ASCII letters, which the email normalization keeps."""
|
||||
factories.UserFactory(sub=None, email="test@éxample.com")
|
||||
"""Email uniqueness should be case-insensitive among users with no sub (pending users)."""
|
||||
factories.UserFactory(sub=None, email="Test@example.com")
|
||||
with pytest.raises(
|
||||
ValidationError, match="Constraint “unique_email_when_sub_is_null” is violated."
|
||||
):
|
||||
factories.UserFactory(sub=None, email="test@Éxample.com")
|
||||
factories.UserFactory(sub=None, email="test@example.com")
|
||||
|
||||
|
||||
def test_models_users_email_not_unique_when_sub_is_set():
|
||||
@@ -177,54 +175,3 @@ def test_models_users_sub_blank_is_accepted():
|
||||
"""sub='' passes validation because blank=True; null is preferred but not enforced."""
|
||||
user = factories.UserFactory.build(sub="")
|
||||
user.full_clean()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email, expected_email",
|
||||
[
|
||||
("John.Doe@Example.COM", "john.doe@example.com"),
|
||||
(" john.doe@example.com ", "john.doe@example.com"),
|
||||
],
|
||||
)
|
||||
def test_models_users_email_normalized_on_save(email, expected_email):
|
||||
"""The email of a user should be stripped and have its ASCII letters only lowercased
|
||||
when saved."""
|
||||
user = factories.UserFactory(email=email)
|
||||
user.refresh_from_db()
|
||||
assert user.email == expected_email
|
||||
|
||||
|
||||
def test_models_users_email_normalized_on_login():
|
||||
"""The last login update at login should store a legacy email normalized."""
|
||||
user = factories.UserFactory()
|
||||
models.User.objects.filter(pk=user.pk).update(email="John.Doe@Example.COM")
|
||||
user.refresh_from_db()
|
||||
|
||||
update_last_login(None, user)
|
||||
|
||||
user.refresh_from_db()
|
||||
assert user.last_login is not None
|
||||
assert user.email == "john.doe@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("email", ["", " "])
|
||||
def test_models_users_email_blank_stored_as_null(email):
|
||||
"""An empty or whitespace-only email should be stored as NULL."""
|
||||
user = factories.UserFactory(email=email)
|
||||
user.refresh_from_db()
|
||||
assert user.email is None
|
||||
|
||||
|
||||
def test_models_users_get_user_by_sub_or_email_active_preferred(settings):
|
||||
"""An active user should be preferred over an inactive one sharing its email."""
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
factories.UserFactory(email="john.doe@example.com", is_active=False)
|
||||
active_user = factories.UserFactory(email="John.Doe@example.com")
|
||||
factories.UserFactory(email="john.doe@example.com", is_active=False)
|
||||
|
||||
assert (
|
||||
models.User.objects.get_user_by_sub_or_email(
|
||||
"unknown-sub", "JOHN.DOE@example.com"
|
||||
)
|
||||
== active_user
|
||||
)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Unit tests for the OIDC_ALLOW_DUPLICATE_EMAILS setting."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from meet.settings import Test
|
||||
|
||||
|
||||
def test_oidc_allow_duplicate_emails_with_email_fallback_is_refused():
|
||||
"""Users identified by email cannot share one, so both settings stop the loading."""
|
||||
with (
|
||||
mock.patch.object(Test, "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION", True),
|
||||
mock.patch.object(Test, "OIDC_ALLOW_DUPLICATE_EMAILS", True),
|
||||
pytest.raises(ValueError, match="OIDC_ALLOW_DUPLICATE_EMAILS"),
|
||||
):
|
||||
Test.post_setup()
|
||||
@@ -27,7 +27,6 @@ from core.utils import (
|
||||
NotificationError,
|
||||
create_livekit_client,
|
||||
generate_token,
|
||||
normalize_email,
|
||||
notify_participants,
|
||||
)
|
||||
|
||||
@@ -421,14 +420,3 @@ def test_notify_participants_success(mock_create_livekit_client):
|
||||
|
||||
# Verify aclose was called
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_normalize_email():
|
||||
"""The "normalize_email" function should strip and lowercase ASCII letters only."""
|
||||
assert normalize_email(" John.Doe@Example.COM ") == "john.doe@example.com"
|
||||
assert normalize_email("Dısk@Example.com") == "dısk@example.com"
|
||||
assert normalize_email("Diſk@Example.com") == "diſk@example.com"
|
||||
assert normalize_email("DisK@Example.com") == "disK@example.com"
|
||||
assert normalize_email("Élise@Example.com") == "Élise@example.com"
|
||||
assert normalize_email("") == ""
|
||||
assert normalize_email(None) == ""
|
||||
|
||||
@@ -345,17 +345,6 @@ def is_uuid(value: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
_ASCII_LOWERCASE_TABLE = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
|
||||
|
||||
|
||||
def normalize_email(email):
|
||||
"""Strip an email and lowercase its ASCII letters only.
|
||||
|
||||
Emails are stored in this form and compared exactly.
|
||||
"""
|
||||
return (email or "").strip().translate(_ASCII_LOWERCASE_TABLE)
|
||||
|
||||
|
||||
def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
|
||||
"""
|
||||
Detect MIME type using multiple methods for better accuracy.
|
||||
|
||||
@@ -617,14 +617,6 @@ class Base(Configuration):
|
||||
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# WARNING: Enabling this setting allows multiple user accounts to share the same email
|
||||
# address. This may cause security issues and is not recommended for production use when
|
||||
# email is activated as fallback for identification
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_USER_SUB_FIELD_IMMUTABLE = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_USER_SUB_FIELD_IMMUTABLE", environ_prefix=None
|
||||
)
|
||||
@@ -1665,15 +1657,6 @@ class Base(Configuration):
|
||||
if cls.LIVEKIT_API_TIMEOUT_SECONDS < 1:
|
||||
raise ValueError("LIVEKIT_API_TIMEOUT_SECONDS must be at least 1")
|
||||
|
||||
if (
|
||||
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
|
||||
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise ValueError(
|
||||
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
|
||||
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously."
|
||||
)
|
||||
|
||||
if (
|
||||
cls.SUMMARY_SERVICE_VERSION == 1
|
||||
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||
|
||||
@@ -11,7 +11,7 @@ server {
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|webp|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ const config: Config = {
|
||||
jsxFramework: 'react',
|
||||
outdir: 'src/styled-system',
|
||||
globalFontface: {},
|
||||
globalVars: {
|
||||
'--global-font-body': 'var(--fonts-sans)',
|
||||
},
|
||||
theme: {
|
||||
...pandaPreset.theme,
|
||||
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -7,7 +7,11 @@ import {
|
||||
} from 'react-aria-components'
|
||||
import { RiArrowDownSFill } from '@remixicon/react'
|
||||
import { css, cx } from '@/styled-system/css'
|
||||
import type { ConnectionTestStepResult } from '../types'
|
||||
import {
|
||||
DIAGNOSTIC_OUTPUT_LANG,
|
||||
getSummaryLang,
|
||||
type ConnectionTestStepResult,
|
||||
} from '../types'
|
||||
import { StepStatusIndicator } from './StepStatusIndicator'
|
||||
|
||||
/** Each step is its own bounded card, collapsed or not. */
|
||||
@@ -65,7 +69,7 @@ const labelClass = css({
|
||||
const valueClass = css({
|
||||
fontFamily: 'mono',
|
||||
textStyle: 'xs',
|
||||
color: 'greyscale.500',
|
||||
color: 'greyscale.600',
|
||||
overflowWrap: 'anywhere',
|
||||
})
|
||||
|
||||
@@ -110,7 +114,11 @@ const StepRowContent = ({ step }: { step: ConnectionTestStepResult }) => {
|
||||
<>
|
||||
<span className={identityClass}>
|
||||
<span className={labelClass}>{t(`steps.${step.id}`)}</span>
|
||||
{step.summary && <span className={valueClass}>{step.summary}</span>}
|
||||
{step.summary && (
|
||||
<span className={valueClass} lang={getSummaryLang(step)}>
|
||||
{step.summary}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<StepStatusIndicator
|
||||
status={step.status}
|
||||
@@ -170,7 +178,7 @@ export const ConnectionTestStepRow = ({
|
||||
{/* Collapsed panels stay in the DOM for aria-controls, but the log
|
||||
lines themselves are only mounted when actually visible. */}
|
||||
{isExpanded && (
|
||||
<ul className={logListClass}>
|
||||
<ul className={logListClass} lang={DIAGNOSTIC_OUTPUT_LANG}>
|
||||
{step.logs?.map((log, index) => (
|
||||
<li key={`${log.level}-${index}`} className={logItemClass}>
|
||||
{log.message}
|
||||
|
||||
@@ -153,7 +153,7 @@ const counterValueClass = css({
|
||||
})
|
||||
|
||||
/** A zero count is context, not a result: it recedes instead of shouting. */
|
||||
const emptyCounterClass = css({ color: 'greyscale.400' })
|
||||
const emptyCounterClass = css({ color: 'greyscale.600' })
|
||||
const emptySquareClass = css({
|
||||
backgroundColor: 'transparent!',
|
||||
border: '1px solid {colors.greyscale.250}',
|
||||
|
||||
@@ -25,10 +25,10 @@ export const statusSquareClass: Record<ConnectionTestStepStatus, string> = {
|
||||
* failure and warning.
|
||||
*/
|
||||
export const statusTextClass: Record<ConnectionTestStepStatus, string> = {
|
||||
pending: css({ color: 'greyscale.500' }),
|
||||
pending: css({ color: 'greyscale.600' }),
|
||||
running: css({ color: 'greyscale.700' }),
|
||||
success: css({ color: 'greyscale.1000' }),
|
||||
warning: css({ color: 'warning', fontWeight: 'medium' }),
|
||||
failed: css({ color: 'danger.600', fontWeight: 'medium' }),
|
||||
skipped: css({ color: 'greyscale.500' }),
|
||||
skipped: css({ color: 'greyscale.600' }),
|
||||
}
|
||||
|
||||
@@ -136,14 +136,16 @@ const ConnectionTest = () => {
|
||||
<h2 className={sectionTitleClass}>
|
||||
{t(`groups.${group.id}`)}
|
||||
</h2>
|
||||
<div className={rowsClass}>
|
||||
<ul className={rowsClass}>
|
||||
{group.steps.map((id) => {
|
||||
const step = stepsById.get(id)
|
||||
return step ? (
|
||||
<ConnectionTestStepRow key={id} step={step} />
|
||||
<li key={id}>
|
||||
<ConnectionTestStepRow step={step} />
|
||||
</li>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
|
||||
@@ -32,6 +32,23 @@ export type ConnectionTestStepResult = {
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const DIAGNOSTIC_OUTPUT_LANG = 'en'
|
||||
|
||||
const LANGUAGE_NEUTRAL_STEPS: ReadonlySet<ConnectionTestStepId> = new Set([
|
||||
'browser',
|
||||
'microphone',
|
||||
'camera',
|
||||
'devices',
|
||||
])
|
||||
|
||||
export const getSummaryLang = ({
|
||||
id,
|
||||
status,
|
||||
}: Pick<ConnectionTestStepResult, 'id' | 'status'>) =>
|
||||
LANGUAGE_NEUTRAL_STEPS.has(id) && status === 'success'
|
||||
? undefined
|
||||
: DIAGNOSTIC_OUTPUT_LANG
|
||||
|
||||
export type ConnectionTestGroupId = 'local' | 'network'
|
||||
|
||||
/** Display order: everything local first, then everything that leaves the machine. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button } from '@/primitives'
|
||||
import { Button, Picture } from '@/primitives'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -30,29 +30,32 @@ const Body = styled('p', {
|
||||
},
|
||||
})
|
||||
|
||||
const Image = styled('img', {
|
||||
base: {
|
||||
maxHeight: '362px',
|
||||
height: '100%',
|
||||
width: 'fit-content',
|
||||
},
|
||||
const image = css({
|
||||
maxHeight: '362px',
|
||||
height: '100%',
|
||||
width: 'fit-content',
|
||||
})
|
||||
|
||||
const Dot = styled('div', {
|
||||
base: {
|
||||
borderRadius: '50%',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '1px',
|
||||
boxSizing: 'border-box',
|
||||
display: 'inline-block',
|
||||
height: '.375rem',
|
||||
height: '.5rem',
|
||||
margin: '0 .25rem',
|
||||
width: '.375rem',
|
||||
width: '.5rem',
|
||||
},
|
||||
variants: {
|
||||
selected: {
|
||||
true: {
|
||||
backgroundColor: 'primary.800',
|
||||
borderColor: 'primary.800',
|
||||
},
|
||||
false: {
|
||||
backgroundColor: 'primary.300',
|
||||
backgroundColor: 'primary.100',
|
||||
borderColor: 'primary.500',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -160,7 +163,6 @@ const carouselNavButton = css({
|
||||
},
|
||||
})
|
||||
|
||||
// todo - optimize how images are imported
|
||||
const SLIDES: Slide[] = [
|
||||
{
|
||||
key: 'slide1',
|
||||
@@ -246,7 +248,12 @@ export const IntroSlider = () => {
|
||||
visible={index === slideIndex}
|
||||
key={index}
|
||||
>
|
||||
<Image src={slide.src} alt="" role="presentation" />
|
||||
<Picture
|
||||
src={slide.src}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className={image}
|
||||
/>
|
||||
<TextAnimation visible={index == slideIndex}>
|
||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||
<Body>{t(`${slide.key}.body`)}</Body>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { getScrollBarWidth } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||
import { TrackLoop } from '@livekit/components-react'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useCarouselTiles } from '../hooks/useCarouselTiles'
|
||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||
|
||||
const MIN_HEIGHT = 130
|
||||
const MIN_WIDTH = 140
|
||||
@@ -23,8 +25,9 @@ export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaEleme
|
||||
* The `CarouselLayout` component displays a list of tracks in a scroll container.
|
||||
* It will display as many tiles as possible and overflow the rest.
|
||||
* @remarks
|
||||
* To ensure visual stability when tiles are reordered due to track updates,
|
||||
* the component uses the `useVisualStableUpdate` hook.
|
||||
* Tiles are ordered by `useCarouselTiles`: screen shares, new speakers and
|
||||
* raised hands enter at the top, right below the local camera, and the other
|
||||
* tiles keep their order.
|
||||
* @example
|
||||
* ```tsx
|
||||
* const tracks = useTracks([Track.Source.Camera]);
|
||||
@@ -66,7 +69,11 @@ export function CarouselLayout({
|
||||
setPrevTiles(tilesThatFit)
|
||||
}
|
||||
|
||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||
const sortedTiles = useCarouselTiles(tracks, maxVisibleTiles)
|
||||
// The carousel scrolls, so only the first tiles are guaranteed on screen.
|
||||
useSpeakerPromotionTrigger(sortedTiles.slice(0, maxVisibleTiles), tracks, {
|
||||
raisedHands: true,
|
||||
})
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (asideEl.current) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
|
||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
useSpeakerPromotionTrigger(pagination.tracks)
|
||||
useSpeakerPromotionTrigger(pagination.tracks, tracks)
|
||||
|
||||
useSwipe(gridEl, {
|
||||
onLeftSwipe: pagination.nextPage,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useRef } from 'react'
|
||||
import { orderCarouselTiles } from '../utils/orderCarouselTiles'
|
||||
|
||||
/**
|
||||
* Carousel counterpart of LiveKit's `useVisualStableUpdate`: same sort, same
|
||||
* stable update, but screen shares and new speakers end up at the top. See
|
||||
* `orderCarouselTiles`.
|
||||
*
|
||||
* Like `useVisualStableUpdate`, it only reorders when its host renders.
|
||||
*/
|
||||
export function useCarouselTiles(
|
||||
tracks: TrackReferenceOrPlaceholder[],
|
||||
maxVisibleTiles: number
|
||||
) {
|
||||
const previous = useRef<TrackReferenceOrPlaceholder[]>([])
|
||||
previous.current = orderCarouselTiles(
|
||||
previous.current,
|
||||
tracks,
|
||||
maxVisibleTiles
|
||||
)
|
||||
return previous.current
|
||||
}
|
||||
@@ -8,41 +8,65 @@ import { useEffect, useReducer, useRef } from 'react'
|
||||
* Tripwire for speaker promotion.
|
||||
*
|
||||
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
||||
* re-render of the host component only when an active speaker has none of
|
||||
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
||||
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
||||
* state and performs the actual swap.
|
||||
* re-render of the host when a speaker has a tile in `allTiles` but none in
|
||||
* `visibleTiles`. That render re-runs the host's tile ordering, which reads
|
||||
* live `participant.isSpeaking` state and performs the actual swap.
|
||||
*
|
||||
* Speakers already visible are ignored. Everything else costs zero React work.
|
||||
* Speakers already visible cost nothing, nor do speakers the layout doesn't
|
||||
* show at all (e.g. the pinned participant, rendered by the focus view).
|
||||
*
|
||||
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
||||
* With `raisedHands`, it also trips when a hand goes up or down while a
|
||||
* raised hand is hidden: the hand that just went up, or the next one waiting
|
||||
* for the slot of a hand that went down. Only for hosts whose ordering
|
||||
* promotes raised hands; others would render for nothing.
|
||||
*
|
||||
* Requires the parent to NOT re-render on these events itself, i.e.
|
||||
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
||||
*/
|
||||
export function useSpeakerPromotionTrigger(
|
||||
sortedTiles: TrackReferenceOrPlaceholder[]
|
||||
/** Tiles inside the span the layout guarantees to speakers. */
|
||||
visibleTiles: TrackReferenceOrPlaceholder[],
|
||||
/** Every tile the layout shows, visible or not. */
|
||||
allTiles: TrackReferenceOrPlaceholder[],
|
||||
{ raisedHands = false }: { raisedHands?: boolean } = {}
|
||||
) {
|
||||
const room = useRoomContext()
|
||||
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
||||
|
||||
// Refs so the listener reads current values without re-subscribing
|
||||
// and without itself being a render dependency.
|
||||
const tilesRef = useRef(sortedTiles)
|
||||
tilesRef.current = sortedTiles
|
||||
// Read by the listeners at event time, so they never re-subscribe.
|
||||
const tilesRef = useRef({ visibleTiles, allTiles })
|
||||
tilesRef.current = { visibleTiles, allTiles }
|
||||
|
||||
useEffect(() => {
|
||||
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
||||
const tiles = tilesRef.current
|
||||
const hiddenSpeakerExists = speakers.some(
|
||||
(speaker) =>
|
||||
!tiles.some((t) => t.participant.identity === speaker.identity)
|
||||
const renderIfHidden = (participants: Participant[]) => {
|
||||
const { visibleTiles, allTiles } = tilesRef.current
|
||||
const visible = new Set(visibleTiles.map((t) => t.participant.identity))
|
||||
const shown = new Set(allTiles.map((t) => t.participant.identity))
|
||||
const hidden = participants.some(
|
||||
({ identity }) => shown.has(identity) && !visible.has(identity)
|
||||
)
|
||||
if (hiddenSpeakerExists) {
|
||||
if (hidden) {
|
||||
forceRender()
|
||||
}
|
||||
}
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||
|
||||
const onAttributesChanged = (changed: Record<string, string>) => {
|
||||
if ('handRaisedAt' in changed) {
|
||||
renderIfHidden(
|
||||
tilesRef.current.allTiles
|
||||
.map((t) => t.participant)
|
||||
.filter((p) => !!p.attributes.handRaisedAt)
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [room])
|
||||
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, renderIfHidden)
|
||||
if (raisedHands) {
|
||||
room.on(RoomEvent.ParticipantAttributesChanged, onAttributesChanged)
|
||||
}
|
||||
return () => {
|
||||
room.off(RoomEvent.ActiveSpeakersChanged, renderIfHidden)
|
||||
room.off(RoomEvent.ParticipantAttributesChanged, onAttributesChanged)
|
||||
}
|
||||
}, [room, raisedHands])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { orderCarouselTiles } from './orderCarouselTiles'
|
||||
|
||||
type Tile = TrackReferenceOrPlaceholder
|
||||
|
||||
// Placeholder tiles are enough: the real LiveKit sort and update run on them.
|
||||
const tile = (
|
||||
identity: string,
|
||||
joinedAt: number,
|
||||
{
|
||||
source = Track.Source.Camera,
|
||||
isLocal = false,
|
||||
isSpeaking = false,
|
||||
hasSpoken = isSpeaking,
|
||||
handRaisedAt,
|
||||
}: {
|
||||
source?: Track.Source
|
||||
isLocal?: boolean
|
||||
isSpeaking?: boolean
|
||||
hasSpoken?: boolean
|
||||
handRaisedAt?: string
|
||||
} = {}
|
||||
) =>
|
||||
({
|
||||
participant: {
|
||||
identity,
|
||||
isLocal,
|
||||
isSpeaking,
|
||||
audioLevel: isSpeaking ? 1 : 0,
|
||||
lastSpokeAt: hasSpoken ? new Date(joinedAt) : undefined,
|
||||
joinedAt: new Date(joinedAt),
|
||||
isCameraEnabled: false,
|
||||
attributes: handRaisedAt ? { handRaisedAt } : {},
|
||||
},
|
||||
source,
|
||||
}) as unknown as Tile
|
||||
|
||||
const names = (tiles: Tile[]) =>
|
||||
tiles.map((t) =>
|
||||
t.source === Track.Source.ScreenShare
|
||||
? `${t.participant.identity}:screen`
|
||||
: t.participant.identity
|
||||
)
|
||||
|
||||
const me = tile('me', 0, { isLocal: true })
|
||||
const [a, b, c, d, e] = ['a', 'b', 'c', 'd', 'e'].map((id, i) =>
|
||||
tile(id, i + 1)
|
||||
)
|
||||
const speaking = (t: Tile) =>
|
||||
tile(t.participant.identity, t.participant.joinedAt!.getTime(), {
|
||||
isSpeaking: true,
|
||||
})
|
||||
const quietAgain = (t: Tile) =>
|
||||
tile(t.participant.identity, t.participant.joinedAt!.getTime(), {
|
||||
hasSpoken: true,
|
||||
})
|
||||
const handUp = (t: Tile, minute = 0) =>
|
||||
tile(t.participant.identity, t.participant.joinedAt!.getTime(), {
|
||||
isLocal: t.participant.isLocal,
|
||||
handRaisedAt: new Date(Date.UTC(2026, 0, 1, 10, minute)).toISOString(),
|
||||
})
|
||||
const screenOf = (t: Tile) =>
|
||||
tile(t.participant.identity, t.participant.joinedAt!.getTime(), {
|
||||
source: Track.Source.ScreenShare,
|
||||
})
|
||||
|
||||
describe('orderCarouselTiles', () => {
|
||||
it('follows the LiveKit sort on the first render', () => {
|
||||
expect(names(orderCarouselTiles([], [d, c, b, a, me], 3))).toEqual([
|
||||
'me',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
])
|
||||
})
|
||||
|
||||
it('puts a new speaker right below the local camera', () => {
|
||||
const previous = orderCarouselTiles([], [me, a, b, c, d, e], 4)
|
||||
const next = orderCarouselTiles(previous, [me, a, b, c, d, speaking(e)], 4)
|
||||
|
||||
// LiveKit alone would give me, a, b, e: `e` takes the slot of `c`.
|
||||
expect(names(next).slice(0, 4)).toEqual(['me', 'e', 'a', 'b'])
|
||||
})
|
||||
|
||||
it('keeps the order of the tiles that stay visible', () => {
|
||||
let tiles = orderCarouselTiles([], [me, a, b, c, d, e], 4)
|
||||
tiles = orderCarouselTiles(tiles, [me, a, b, c, speaking(d), e], 4)
|
||||
expect(names(tiles).slice(0, 4)).toEqual(['me', 'd', 'a', 'b'])
|
||||
|
||||
// `d` went quiet, `e` speaks: `d` stays ahead of `a`, `b` is evicted.
|
||||
tiles = orderCarouselTiles(
|
||||
tiles,
|
||||
[me, a, b, c, quietAgain(d), speaking(e)],
|
||||
4
|
||||
)
|
||||
expect(names(tiles).slice(0, 4)).toEqual(['me', 'e', 'd', 'a'])
|
||||
})
|
||||
|
||||
it('does not move a visible participant who speaks', () => {
|
||||
const previous = orderCarouselTiles([], [me, a, b, c, d], 4)
|
||||
const next = orderCarouselTiles(previous, [me, a, b, speaking(c), d], 4)
|
||||
|
||||
expect(names(next)).toEqual(['me', 'a', 'b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('keeps screen shares above a new speaker', () => {
|
||||
const previous = orderCarouselTiles([], [me, screenOf(a), a, b, c, d], 4)
|
||||
expect(names(previous).slice(0, 4)).toEqual(['me', 'a:screen', 'a', 'b'])
|
||||
|
||||
const next = orderCarouselTiles(
|
||||
previous,
|
||||
[me, screenOf(a), a, b, c, speaking(d)],
|
||||
4
|
||||
)
|
||||
expect(names(next).slice(0, 4)).toEqual(['me', 'a:screen', 'd', 'a'])
|
||||
})
|
||||
|
||||
it('puts a screen share that starts below the local camera', () => {
|
||||
const previous = orderCarouselTiles([], [me, a, b, c, d], 4)
|
||||
const next = orderCarouselTiles(previous, [me, a, b, c, d, screenOf(d)], 4)
|
||||
|
||||
expect(names(next).slice(0, 4)).toEqual(['me', 'd:screen', 'a', 'b'])
|
||||
})
|
||||
|
||||
it('fills the slot of a participant who left without reordering', () => {
|
||||
const previous = orderCarouselTiles([], [me, a, b, c, d], 4)
|
||||
const next = orderCarouselTiles(previous, [me, b, c, d], 4)
|
||||
|
||||
expect(names(next)).toEqual(['me', 'd', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('brings a raised hand to the top like a speaker', () => {
|
||||
const previous = orderCarouselTiles([], [me, a, b, c, d, e], 4)
|
||||
const next = orderCarouselTiles(previous, [me, a, b, c, d, handUp(e)], 4)
|
||||
|
||||
expect(names(next).slice(0, 4)).toEqual(['me', 'e', 'a', 'b'])
|
||||
})
|
||||
|
||||
it('keeps a raised hand visible when others speak', () => {
|
||||
let tiles = orderCarouselTiles([], [me, a, b, c, d, e], 3)
|
||||
tiles = orderCarouselTiles(tiles, [me, a, b, c, d, handUp(e)], 3)
|
||||
expect(names(tiles).slice(0, 3)).toEqual(['me', 'e', 'a'])
|
||||
|
||||
// `d` speaks: it evicts `a`, not the raised hand.
|
||||
tiles = orderCarouselTiles(tiles, [me, a, b, c, speaking(d), handUp(e)], 3)
|
||||
expect(names(tiles).slice(0, 3)).toEqual(['me', 'd', 'e'])
|
||||
})
|
||||
|
||||
it('lets the current speaker in when hands fill the carousel', () => {
|
||||
let tiles = orderCarouselTiles([], [me, handUp(a, 1), handUp(b, 2), c], 3)
|
||||
expect(names(tiles).slice(0, 3)).toEqual(['me', 'a', 'b'])
|
||||
|
||||
// The last hand raised gives way to the speaker.
|
||||
tiles = orderCarouselTiles(
|
||||
tiles,
|
||||
[me, handUp(a, 1), handUp(b, 2), speaking(c)],
|
||||
3
|
||||
)
|
||||
expect(names(tiles).slice(0, 3)).toEqual(['me', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('gives the slot of a lowered hand to the next one waiting', () => {
|
||||
let tiles = orderCarouselTiles(
|
||||
[],
|
||||
[me, handUp(a, 1), handUp(b, 2), handUp(c, 3)],
|
||||
3
|
||||
)
|
||||
expect(names(tiles).slice(0, 3)).toEqual(['me', 'a', 'b'])
|
||||
|
||||
// `a` lowers their hand: `c` was waiting and takes over.
|
||||
tiles = orderCarouselTiles(tiles, [me, a, handUp(b, 2), handUp(c, 3)], 3)
|
||||
expect(names(tiles).slice(0, 3)).toEqual(['me', 'c', 'b'])
|
||||
})
|
||||
|
||||
it('shows the first hands raised when there are too many', () => {
|
||||
const tiles = orderCarouselTiles(
|
||||
[],
|
||||
[me, a, handUp(b, 3), handUp(c, 1), handUp(d, 2)],
|
||||
3
|
||||
)
|
||||
|
||||
expect(names(tiles).slice(0, 3)).toEqual(['me', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('keeps the local camera first when the local hand is raised', () => {
|
||||
const tiles = orderCarouselTiles([], [a, screenOf(a), b, handUp(me)], 3)
|
||||
|
||||
expect(names(tiles)).toEqual(['me', 'a:screen', 'a', 'b'])
|
||||
})
|
||||
|
||||
it('is stable when run twice on the same input', () => {
|
||||
const tracks = [me, a, b, c, d, speaking(e)]
|
||||
const previous = orderCarouselTiles([], [me, a, b, c, d, e], 4)
|
||||
|
||||
const once = orderCarouselTiles(previous, tracks, 4)
|
||||
expect(names(orderCarouselTiles(once, tracks, 4))).toEqual(names(once))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
log,
|
||||
sortTrackReferences,
|
||||
updatePages,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
|
||||
// LiveKit's track reference id changes when a placeholder gets its track;
|
||||
// this key doesn't, so turning a camera on is not seen as a new tile.
|
||||
const keyOf = (tile: TrackReferenceOrPlaceholder) =>
|
||||
`${tile.participant.identity}_${tile.source}`
|
||||
|
||||
/** When the participant raised their hand, if it is still raised. */
|
||||
const handRaisedAt = (tile: TrackReferenceOrPlaceholder) => {
|
||||
if (tile.source !== Track.Source.Camera) return undefined
|
||||
const time = Date.parse(tile.participant.attributes.handRaisedAt ?? '')
|
||||
return Number.isNaN(time) ? undefined : time
|
||||
}
|
||||
|
||||
/**
|
||||
* LiveKit's sort knows nothing about raised hands. This moves them right
|
||||
* after the tiles that must stay ahead (local camera, screen shares, whoever
|
||||
* is speaking now), first raised first, so they are among the first tiles
|
||||
* picked as visible for as long as the hand is up.
|
||||
*/
|
||||
const sortWithRaisedHands = (tracks: TrackReferenceOrPlaceholder[]) => {
|
||||
const rank = (tile: TrackReferenceOrPlaceholder) => {
|
||||
const { isLocal, isSpeaking } = tile.participant
|
||||
if (isLocal || isSpeaking || tile.source === Track.Source.ScreenShare) {
|
||||
return 0
|
||||
}
|
||||
return handRaisedAt(tile) === undefined ? 2 : 1
|
||||
}
|
||||
return sortTrackReferences(tracks).sort((a, b) => {
|
||||
if (rank(a) !== rank(b)) return rank(a) - rank(b)
|
||||
// Equal ranks keep LiveKit's order, except raised hands: first come first.
|
||||
return rank(a) === 1 ? handRaisedAt(a)! - handRaisedAt(b)! : 0
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders the carousel tiles; the first `maxVisibleTiles` are the visible ones.
|
||||
*
|
||||
* This is what LiveKit's `useVisualStableUpdate` does, plus one step:
|
||||
* 1. `sortTrackReferences` decides which tiles deserve to be visible, with
|
||||
* raised hands added to it, see `sortWithRaisedHands`;
|
||||
* 2. `updatePages` applies that to the `previous` order with as few moves as
|
||||
* possible: an incoming tile is swapped with the outgoing one;
|
||||
* 3. added here: within the visible tiles, the local camera goes first, then
|
||||
* the screen shares, then the speakers and raised hands that step 2 just
|
||||
* brought in. The other tiles keep the order step 2 gave them.
|
||||
*
|
||||
* Step 2 alone leaves an incoming tile wherever the evicted one happened to
|
||||
* be, often at the bottom of the column.
|
||||
*/
|
||||
export function orderCarouselTiles(
|
||||
previous: TrackReferenceOrPlaceholder[],
|
||||
tracks: TrackReferenceOrPlaceholder[],
|
||||
maxVisibleTiles: number
|
||||
): TrackReferenceOrPlaceholder[] {
|
||||
const sorted = sortWithRaisedHands(tracks)
|
||||
|
||||
let stable = sorted
|
||||
try {
|
||||
stable = updatePages(previous, sorted, maxVisibleTiles)
|
||||
} catch (error) {
|
||||
log.error('Error while running updatePages(): ', error)
|
||||
}
|
||||
|
||||
const previouslyVisible = new Set(
|
||||
previous.slice(0, maxVisibleTiles).map(keyOf)
|
||||
)
|
||||
const rank = (tile: TrackReferenceOrPlaceholder) => {
|
||||
if (tile.source === Track.Source.ScreenShare) return 1
|
||||
if (tile.participant.isLocal) return 0
|
||||
const justCameIn = !previouslyVisible.has(keyOf(tile))
|
||||
const wantsAttention =
|
||||
tile.participant.isSpeaking || handRaisedAt(tile) !== undefined
|
||||
return justCameIn && wantsAttention ? 2 : 3
|
||||
}
|
||||
|
||||
return [
|
||||
// Array.prototype.sort is stable: tiles of equal rank keep their order.
|
||||
...stable.slice(0, maxVisibleTiles).sort((a, b) => rank(a) - rank(b)),
|
||||
...stable.slice(maxVisibleTiles),
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { A, Div, H, Text } from '@/primitives'
|
||||
import { A, Div, H, Picture, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LoginPrompt } from './LoginPrompt'
|
||||
@@ -45,7 +45,7 @@ export const NoAccessView = ({
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<img
|
||||
<Picture
|
||||
src={imagePath}
|
||||
alt=""
|
||||
className={css({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Div, H, Text } from '@/primitives'
|
||||
import { Button, Div, Picture, Text } from '@/primitives'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
@@ -169,7 +169,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<img
|
||||
<Picture
|
||||
src="/assets/intro-slider/4.png"
|
||||
alt=""
|
||||
className={css({
|
||||
@@ -189,9 +189,9 @@ export const ScreenRecordingSidePanel = () => {
|
||||
})}
|
||||
/>
|
||||
<VStack gap={0} marginBottom={15}>
|
||||
<H lvl={1} margin={'sm'} fullWidth>
|
||||
<Text as="h2" variant="h1" margin="sm" fullWidth>
|
||||
{t('heading')}
|
||||
</H>
|
||||
</Text>
|
||||
<LimitDescription
|
||||
keyPrefix={'screenRecording'}
|
||||
supportArticleLink={data?.support?.help_article_recording}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { A, Button, Div, H, Text } from '@/primitives'
|
||||
import { A, Button, Div, Picture, Text } from '@/primitives'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
@@ -183,7 +183,7 @@ export const TranscriptSidePanel = () => {
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<img
|
||||
<Picture
|
||||
src="/assets/intro-slider/3.png"
|
||||
alt=""
|
||||
className={css({
|
||||
@@ -203,9 +203,9 @@ export const TranscriptSidePanel = () => {
|
||||
})}
|
||||
/>
|
||||
<VStack gap={0} marginBottom={15}>
|
||||
<H lvl={1} margin={'sm'}>
|
||||
<Text as="h2" variant="h1" margin="sm" fullWidth>
|
||||
{t('heading')}
|
||||
</H>
|
||||
</Text>
|
||||
<LimitDescription
|
||||
keyPrefix={'transcript'}
|
||||
supportArticleLink={data?.support?.help_article_transcript}
|
||||
@@ -240,6 +240,9 @@ export const TranscriptSidePanel = () => {
|
||||
<Button
|
||||
variant="text"
|
||||
size="xs"
|
||||
aria-label={t('details.languageAriaLabel', {
|
||||
language: selectedLanguageLabel,
|
||||
})}
|
||||
onPress={() =>
|
||||
openSettingsDialog(SettingsDialogExtendedKey.TRANSCRIPTION)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mediaUrl } from '@/api/mediaUrl'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { UserAware } from '@/features/auth/components/UserAware'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { H, LinkButton, Text } from '@/primitives'
|
||||
import { H, LinkButton, Picture, Text } from '@/primitives'
|
||||
import { formatDate } from '@/utils/formatDate'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
@@ -118,7 +118,7 @@ const RecordingDownload = () => {
|
||||
<Screen layout="centered" footer={false}>
|
||||
<Center>
|
||||
<VStack>
|
||||
<img
|
||||
<Picture
|
||||
src="/assets/intro-slider/4.png"
|
||||
alt={''}
|
||||
className={css({
|
||||
|
||||
@@ -102,46 +102,93 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
</Heading>
|
||||
)}
|
||||
<TabList border={false}>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.ACCOUNT}>
|
||||
<Tab
|
||||
icon
|
||||
highlight
|
||||
id={SettingsDialogExtendedKey.ACCOUNT}
|
||||
aria-label={t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
|
||||
>
|
||||
<RiAccountCircleLine />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.AUDIO}>
|
||||
<Tab
|
||||
icon
|
||||
highlight
|
||||
id={SettingsDialogExtendedKey.AUDIO}
|
||||
aria-label={t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
|
||||
>
|
||||
<RiSpeakerLine />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.VIDEO}>
|
||||
<Tab
|
||||
icon
|
||||
highlight
|
||||
id={SettingsDialogExtendedKey.VIDEO}
|
||||
aria-label={t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
|
||||
>
|
||||
<RiVideoOnLine />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.GENERAL}>
|
||||
<Tab
|
||||
icon
|
||||
highlight
|
||||
id={SettingsDialogExtendedKey.GENERAL}
|
||||
aria-label={t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
|
||||
>
|
||||
<RiSettings3Line />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
|
||||
<Tab
|
||||
icon
|
||||
highlight
|
||||
id={SettingsDialogExtendedKey.NOTIFICATIONS}
|
||||
aria-label={t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
|
||||
>
|
||||
<RiNotification3Line />
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.SHORTCUTS}>
|
||||
<Tab
|
||||
icon
|
||||
highlight
|
||||
id={SettingsDialogExtendedKey.SHORTCUTS}
|
||||
aria-label={t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
|
||||
>
|
||||
<RiKeyboardBoxLine />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
|
||||
</Tab>
|
||||
{isAdminOrOwner && (
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
|
||||
<Tab
|
||||
icon
|
||||
highlight
|
||||
id={SettingsDialogExtendedKey.TRANSCRIPTION}
|
||||
aria-label={t(
|
||||
`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`
|
||||
)}
|
||||
>
|
||||
<Icon name="speech_to_text" />
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
|
||||
</Tab>
|
||||
)}
|
||||
{showRecordingTab && (
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.RECORDING}>
|
||||
<Tab
|
||||
icon
|
||||
highlight
|
||||
id={SettingsDialogExtendedKey.RECORDING}
|
||||
aria-label={t(`tabs.${SettingsDialogExtendedKey.RECORDING}`)}
|
||||
>
|
||||
<RiRecordCircleLine />
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.RECORDING}`)}
|
||||
</Tab>
|
||||
)}
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
|
||||
<Tab
|
||||
icon
|
||||
highlight
|
||||
id={SettingsDialogExtendedKey.ACCESSIBILITY}
|
||||
aria-label={t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
|
||||
>
|
||||
<RiEyeLine />
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const useLanguageLabels = () => {
|
||||
key: lang,
|
||||
value: lang,
|
||||
label: languageLabels[lang],
|
||||
lang,
|
||||
}))
|
||||
return {
|
||||
languagesList,
|
||||
|
||||
@@ -492,6 +492,7 @@
|
||||
"destination": "Ein neues Dokument wird erstellt auf",
|
||||
"destinationUnknown": "Ein neues Dokument wird erstellt",
|
||||
"language": "Meeting-Sprache:",
|
||||
"languageAriaLabel": "{{language}}, Meeting-Sprache ändern",
|
||||
"recording": "Auch eine Videoaufzeichnung starten"
|
||||
},
|
||||
"button": {
|
||||
|
||||
@@ -492,6 +492,7 @@
|
||||
"destination": "A new document will be created on",
|
||||
"destinationUnknown": "A new document will be created",
|
||||
"language": "Meeting language:",
|
||||
"languageAriaLabel": "{{language}}, change the meeting language",
|
||||
"recording": "Also start a video recording"
|
||||
},
|
||||
"button": {
|
||||
|
||||
@@ -491,6 +491,7 @@
|
||||
"destination": "Se creará un nuevo documento en",
|
||||
"destinationUnknown": "Se creará un nuevo documento",
|
||||
"language": "Idioma de la reunión:",
|
||||
"languageAriaLabel": "{{language}}, cambiar el idioma de la reunión",
|
||||
"recording": "Iniciar también una grabación de vídeo"
|
||||
},
|
||||
"button": {
|
||||
|
||||
@@ -492,6 +492,7 @@
|
||||
"destination": "Un nouveau document sera créé sur",
|
||||
"destinationUnknown": "Un nouveau document sera créé",
|
||||
"language": "Langue de la réunion :",
|
||||
"languageAriaLabel": "{{language}}, modifier la langue de la réunion",
|
||||
"recording": "Démarrer aussi un enregistrement vidéo"
|
||||
},
|
||||
"button": {
|
||||
|
||||
@@ -492,6 +492,7 @@
|
||||
"destination": "Er wordt een nieuw document aangemaakt op",
|
||||
"destinationUnknown": "Een nieuw document wordt aangemaakt",
|
||||
"language": "Vergadertalen:",
|
||||
"languageAriaLabel": "{{language}}, vergadertaal wijzigen",
|
||||
"recording": "Start ook een video-opname"
|
||||
},
|
||||
"button": {
|
||||
|
||||
@@ -492,6 +492,7 @@
|
||||
"destination": "Um novo documento será criado em",
|
||||
"destinationUnknown": "Um novo documento será criado",
|
||||
"language": "Idioma da reunião:",
|
||||
"languageAriaLabel": "{{language}}, alterar o idioma da reunião",
|
||||
"recording": "Também iniciar uma gravação"
|
||||
},
|
||||
"button": {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type ComponentPropsWithoutRef } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
const WEBP_CONVERTIBLE_EXTENSION = /\.(png|jpe?g)$/i
|
||||
|
||||
export type PictureProps = Omit<ComponentPropsWithoutRef<'img'>, 'src'> & {
|
||||
src: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the WebP version of an image, and falls back on the original file
|
||||
* for browsers that do not support WebP.
|
||||
*
|
||||
* The `<picture>` wrapper uses `display: contents`, so the `<img>` is laid out
|
||||
* exactly as if it was rendered alone: style it through the usual props.
|
||||
*/
|
||||
export const Picture = ({ src, alt, ...props }: PictureProps) => {
|
||||
const hasWebpVersion = WEBP_CONVERTIBLE_EXTENSION.test(src)
|
||||
return (
|
||||
<picture className={css({ display: 'contents' })}>
|
||||
{hasWebpVersion && (
|
||||
<source
|
||||
srcSet={src.replace(WEBP_CONVERTIBLE_EXTENSION, '.webp')}
|
||||
type="image/webp"
|
||||
/>
|
||||
)}
|
||||
<img src={src} alt={alt} {...props} />
|
||||
</picture>
|
||||
)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export type SelectProps<T> = Omit<
|
||||
> & {
|
||||
iconComponent?: RemixiconComponentType
|
||||
label: ReactNode
|
||||
items: Array<{ value: T; label: ReactNode }>
|
||||
items: Array<{ value: T; label: ReactNode; lang?: string }>
|
||||
errors?: ReactNode
|
||||
placement?: Placement
|
||||
variant?: 'light' | 'dark'
|
||||
@@ -150,7 +150,11 @@ export const Select = <T extends string | number>({
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
{item.label}
|
||||
{item.lang ? (
|
||||
<span lang={item.lang}>{item.label}</span>
|
||||
) : (
|
||||
item.label
|
||||
)}
|
||||
{isSelected && (
|
||||
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ export { Link } from './Link'
|
||||
export { Menu } from './Menu'
|
||||
export { MenuList } from './MenuList'
|
||||
export { P } from './P'
|
||||
export { Picture } from './Picture'
|
||||
export { Popover } from './Popover'
|
||||
export { ScreenReaderAnnouncer } from './ScreenReaderAnnouncer'
|
||||
export { Text } from './Text'
|
||||
|
||||
@@ -6,6 +6,13 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* RGAA 10.5: every text color must be paired with a declared background
|
||||
color, so custom browser or system colors cannot make text unreadable. */
|
||||
body {
|
||||
background-color: var(--colors-default-bg);
|
||||
color: var(--colors-default-text);
|
||||
}
|
||||
|
||||
:root {
|
||||
--avatar-cap-height: 0.7;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ data:
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|webp|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user