Compare commits

..

14 Commits

Author SHA1 Message Date
lebaudantoine 06c4e277db ⚡️(frontend) serve illustrations as WebP with a PNG fallback
The three intro slider illustrations were the heaviest part of the
homepage. On a cold load of the production homepage (cache disabled),
the browser transferred 2.67 MB in total, of which 2.00 MB was these
three PNG files: 75% of the page weight, and about 3.5 times all the
JavaScript combined (579 kB).

HTTP compression cannot help here, PNG being already compressed. The
files are heavy because of the grain texture applied on the
illustrations, which a lossless format has to store pixel by pixel.

Encoded as WebP at quality 90, which preserves the grain, the three
slides go from 2.00 MB to 182 kB (-91%). The homepage cold load drops
from 2.67 MB to about 0.86 MB (-68%), and the illustrations now
account for about 21% of the transfer instead of 75%.

The fourth illustration, used on the recording screens, gets the same
treatment (721 kB to 84 kB).

We checked in PostHog that most of our users are on browser versions
supporting WebP. For the remaining ones, a new Picture primitive
renders a <picture> element with the WebP file as a source and the
original PNG as the <img> fallback, so nobody loses the illustrations.

Add webp to the nginx static assets location, otherwise the new files
would be served without the 30 days cache headers.
2026-11-14 14:10:51 +01:00
lebaudantoine b6b3ce28db ♿️(frontend) name settings tabs on small screens
Below 800px the settings tabs only render their icon, which left
each tab, and the tab panel it labels, without an accessible name.
Screen reader users could not tell which settings section they
were opening.

Set aria-label on each tab with its existing translated label.
On wide screens it matches the visible text, so nothing changes.

Fixes #1855
2026-11-14 01:05:10 +01:00
lebaudantoine f27f2fb7de ♿️(frontend) raise contrast of inactive intro slider dots
Inactive dots of the home intro slider were 1.57:1 on white, below
the 3:1 RGAA 3.3 asks for, so low-vision users could not tell how
many slides exist.

They are now hollow: a primary.100 fill with a primary.500 outline
at 4.22:1 on white. This passes without darkening the dots so much
that they blend with the active one. Dots grow from 6px to 8px to
keep the outline legible.

Fixes #1851
2026-11-14 01:05:10 +01:00
lebaudantoine 061025b928 ♿️(frontend) structure connection test cards as lists
The step cards of each connection test section are a series of
similar items but were rendered as plain divs, so assistive
technologies could not announce them as a list or give the item
count.

Render each series as a ul with one li per card. The heading and
accordion stay inside the li. No visual change.

Fixes #1903
2026-11-14 01:05:10 +01:00
lebaudantoine 100044eeaa ♿️(frontend) warn before language change in settings
Selecting a language in the settings modal translates the whole
interface immediately. Nothing announced this beforehand, so a
screen reader user could end up in a language they don't read,
with the dialog title already announced in it.

Add a visible description under the "Langue de l'application"
field, linked to the select trigger with aria-describedby, stating
that the change applies to the whole interface on selection.

This satisfies RGAA 7.4 (WCAG 3.2.2 On Input) without adding an
extra confirmation step.

Fixes #1859
2026-11-14 01:05:10 +01:00
lebaudantoine e158b4faaf ♿️(frontend) fix grey text contrast on the connection test page
Zero counters, pending and skipped labels and step summaries used
greys below the 4.5:1 minimum of RGAA 3.2. They now use greyscale.600,
which passes on white, hovered and expanded rows.

Fixes #1897
2026-11-14 01:05:10 +01:00
lebaudantoine 3a1f0a342d ♿️(frontend) mark the language of each option in language selects
Language names are listed in their own language ("English",
"Deutsch", "Nederlands"...), but nothing told assistive technologies
so. Screen readers pronounced them with the rules of the interface
language, which can make them hard to recognise.

Select items accept an optional `lang`, rendered on a span around
the label. useLanguageLabels sets it for every language it returns.

RGAA 8.7

Fixes #1861
2026-11-13 23:38:16 +01:00
lebaudantoine 62571cd9fc ♿️(frontend) declare a background color with the body text color
Text colors were set without a matching background on the document.
With custom browser or system colors, the user's background could
end up behind our text color and make it unreadable.

Set both the default background and text colors on body, so they
are always defined as a pair.

RGAA 10.5

Fixes #1863
2026-11-13 23:38:05 +01:00
lebaudantoine 8fccf46429 ♿️(frontend) give the transcript language button an explicit name
In the transcript panel, the button showing the meeting language
opens the transcription settings, but its accessible name was only
the language itself, e.g. "Français (fr)". Screen reader users had no
way to know it changes the meeting language.

Add an aria-label that keeps the language and states the action. The
visible label is unchanged. Translated in all six locales.

RGAA 7.1

Fixes #1868
2026-11-13 23:37:48 +01:00
lebaudantoine 9481350383 ♿️(frontend) use level 2 headings in the recording side panels
The side panel already exposes its title as a level 1 heading. The
transcript and screen recording panels rendered their intro line as a
second h1, so screen reader users navigating by headings could not
tell the panel title from its subtitle.

Render both intro headings as h2 while keeping the h1 visual style,
so the hierarchy is correct and nothing changes on screen.

RGAA 9.1

Fixes #1882
2026-11-13 23:29:39 +01:00
lebaudantoine e9a58d270b 🚸(frontend) bring raised hands into view in the carousel
A raised hand matters as much as someone speaking, but it went
unnoticed when the tile was scrolled out of the carousel. It is now
brought to the top like a new speaker and stays visible while raised.
2026-11-13 23:26:49 +01:00
lebaudantoine e3351075aa 🚸(frontend) bring new speakers to the top of the carousel
In the focus layout, when someone off screen started speaking, their
tile replaced whichever tile was evicted, often at the bottom of the
column. A screen share that started did the same. Users had to scan
the whole column to find who was talking, and the spot changed every
time.

New speakers now always appear at the top, right below the local
camera and the screen shares, and the other tiles slide down by one.
The most relevant tiles are where the eye already is, and the column
moves in a predictable way.

The ordering still relies on LiveKit's sort and stable update; only
the position of the incoming tile changes. This is the enhancement
#1805 was after.
2026-11-13 23:26:48 +01:00
lebaudantoine 68198e9961 🐛(frontend) promote the active speaker in the carousel layout
Regression: in the focus layout, a participant who started speaking
while their tile was outside the visible part of the carousel was no
longer brought into view.

Cause: 81f23aeb moved useTracks in StageLayout to `updateOnlyOn: []`
so the whole stage stops re-rendering on every ActiveSpeakersChanged.
useVisualStableUpdate only promotes speakers when its host renders, so
it needs something to wake it up. The grid layout got that with
useSpeakerPromotionTrigger in the same commit; the carousel did not,
and nothing else re-renders it on speaker events.

Fix: the carousel now uses the same tripwire. Unlike the grid, which
passes its current page, the carousel renders every tile in a scroll
container, so only the first maxVisibleTiles are passed as visible.

The tripwire also takes the full list of tiles the layout shows and
ignores speakers who have none. In the focus layout the pinned camera
is rendered by the focus view, not the carousel: without this, each
speaker event from that participant would force a render that cannot
promote anyone. The grid passes all its tracks, so its behaviour is
unchanged.

Known limit, not addressed here: when a single tile fits, it stays on
the local camera, which useVisualStableUpdate sorts first, so a remote
speaker cannot be promoted and the tripwire keeps firing for them.

This supersedes #1805, which will be closed. On top of this fix, it
pinned the active speaker to a fixed slot next to the local camera,
first with a 2s hold, a reserved slot and an eligibility check
(867a14d0), then simplified to the last remote speaker with no timer
(378e3d01). That is an enhancement rather than part of the fix, and it
is left out here to keep the regression fix small. It can come back in
a dedicated PR; it would also cover the single-tile case above.
2026-11-13 23:26:48 +01:00
lebaudantoine e3140e0300 🐛(frontend) apply the sans font token on every page
Since routes are lazy loaded, the LiveKit stylesheet only ships
with the room chunk. It was the only rule applying `--fonts-sans`
to the document, so pages outside a room fell back to Panda's
preflight system font stack, ignoring custom CSS themes (e.g.
Marianne in the DINUM image) and accessibility fonts.

Point Panda's `--global-font-body` at `--fonts-sans` so the
document font follows the token regardless of which chunks are
loaded.
2026-11-13 23:26:46 +01:00
61 changed files with 651 additions and 869 deletions
+5 -6
View File
@@ -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
-9
View File
@@ -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
+1 -1
View File
@@ -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";
}
+1 -1
View File
@@ -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";
}
+1 -1
View File
@@ -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 |
+1 -2
View File
@@ -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 | — |
+1 -2
View File
@@ -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 | |
+2 -40
View File
@@ -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):
+13 -26
View File
@@ -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",
),
]
+2 -51
View File
@@ -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 -57
View File
@@ -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()
-12
View File
@@ -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) == ""
-11
View File
@@ -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.
-17
View File
@@ -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
+1 -1
View File
@@ -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";
}
+3
View File
@@ -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,
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+1
View File
@@ -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": {
+30
View File
@@ -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>
)
}
+6 -2
View File
@@ -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>
)}
+1
View File
@@ -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'
+7
View File
@@ -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";
}