Compare commits

...

9 Commits

Author SHA1 Message Date
lebaudantoine 78743f06ae 🐛(backend) fix info panel crash for unregistered rooms
The info panel was crashing when opening a room that was not
registered in the database (with ALLOW_UNREGISTERED_ROOMS=true),
because the API response did not include the room slug.

Instead of adding frontend fallbacks, update the unregistered-room
response to include the slug, keeping the API contract consistent
with registered rooms.

Note: this still relies on the unregistered-room response staying
aligned with the registered-room schema. Any future divergence
between the two responses could introduce similar issues.
A refactoring on the backend side is needed.

It closes #1441.
2026-07-10 19:34:29 +02:00
lebaudantoine 2bc5e47c75 🚸(frontend) initialize the join input name with the persisted full name
Prefill the "name" field on the join screen with the full name
persisted in the database, instead of leaving it empty by default.
2026-07-10 19:02:26 +02:00
lebaudantoine 0d5136206f (all) allow forcing SSO display name for authenticated users
Add a new setting that controls whether an authenticated user can
rename or modify their display name, or if they must use the one
returned by the SSO.

When the setting is disabled, only anonymous users can set a display
name freely; authenticated users always use their SSO display name.

Requested by many self-hosters.
2026-07-10 19:02:26 +02:00
lebaudantoine 05a67320b1 🩹(backend) fix the LiveKit token to use full name as display name
Use the user's full name as the display name in the emitted LiveKit
token instead of the email.

Requested by several self-hosters.
2026-07-10 19:02:26 +02:00
lebaudantoine 2fbaa49089 ♻️(frontend) refactor the username storage
Create a new dedicated store for user choices, starting with the
username, to decouple this part from any LiveKit elements. This is
better for tree-shaking, and depending on LiveKit for storing the
username brings no real value.

The refactoring will be continued later for the other user choices
that can be persisted.
2026-07-10 19:02:26 +02:00
lebaudantoine 76542b2235 🔧(tilt) configure Tilt stack for full-name and short-name mapping
Update the Tilt dev stack configuration so that the full-name and
short-name claim mapping works correctly with the current Keycloak
configuration.
2026-07-10 19:02:26 +02:00
lebaudantoine 10231b0333 🩹(backend) identify externally provisioned users to PostHog
In the external API, applications authenticate with a
client_id/secret and can provision users with only an email. In that
flow, users are not identified to PostHog, so the user DB id alone
is not enough to identify them in analytics afterwards.

The issue does not exist in the public API, where users are
authenticated and therefore already identified.

Inspired by @flochehab's approach in summary.
2026-07-10 10:47:04 +02:00
Cyril 9b9e6578ee (frontend) add participant color gradient when camera is off
use participant color radial gradient for video-off placeholder
2026-07-10 10:46:46 +02:00
lebaudantoine 0c81d29ee7 (backend) allow searching the recording admin table by owner email
Add the owner's email to the searchable fields of the recording
admin table. This makes it much more convenient for the support team
to look up recordings by user.
2026-07-10 10:15:57 +02:00
21 changed files with 265 additions and 44 deletions
+12
View File
@@ -8,10 +8,22 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(backend) allow searching the recording admin table by owner email
- ✨(frontend) add participant color gradient when camera is off #1490
- ✨(all) allow forcing SSO display name for authenticated users
### Changed
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
- 🚸(frontend) initialize the join input name with the persisted full name
### Fixed
- 🩹(backend) identify externally provisioned users to PostHog
- 🐛(backend) fix info panel crash for unregistered rooms
## [1.23.0] - 2026-07-08
+8 -1
View File
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
search_fields = [
"status",
"=id",
"worker_id",
"room__slug",
"=room__id",
"accesses__user__email",
]
list_display = (
"id",
"status",
+3
View File
@@ -68,6 +68,9 @@ def get_frontend_configuration(request):
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
},
"authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
),
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
+4
View File
@@ -92,6 +92,7 @@ from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion
from ..authentication.livekit import LiveKitTokenAuthentication
from ..models import RoomAccessLevel
from . import permissions, serializers, throttling
from .feature_flag import FeatureFlag
@@ -267,6 +268,9 @@ class RoomViewSet(
username = request.query_params.get("username", None)
data = {
"id": None,
"slug": slug,
"is_administrable": False,
"access_level": RoomAccessLevel.PUBLIC,
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": slug,
@@ -215,5 +215,6 @@ class RoomViewSet(
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
"$set": {"email": self.request.user.email},
},
)
@@ -130,6 +130,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
assert response.status_code == 200
assert response.json() == {
"id": None,
"slug": "unregistered-room",
"access_level": "public",
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": "unregistered-room",
@@ -162,6 +165,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
assert response.status_code == 200
assert response.json() == {
"id": None,
"slug": "reunion",
"access_level": "public",
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": "reunion",
+97 -1
View File
@@ -2,13 +2,109 @@
Test utils functions
"""
# pylint: disable=W0621
import json
from unittest import mock
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
import jwt
import pytest
from livekit.api import TwirpError
from core.utils import NotificationError, create_livekit_client, notify_participants
from core.factories import UserFactory
from core.utils import (
NotificationError,
create_livekit_client,
generate_token,
notify_participants,
)
pytestmark = pytest.mark.django_db
def decode_token(token: str) -> dict:
"""Decode a LiveKit JWT access token for inspection."""
return jwt.decode(
token,
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
)
def test_generate_token_authenticated_uses_full_name():
"""The token's display name should default to the user's full name."""
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
assert claims["sub"] == str(user.sub)
def test_generate_token_authenticated_fallback_user_representation():
"""
When the user has no full name, the token's display name should fall back
to the user's string representation.
"""
user = UserFactory(full_name=None)
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == str(user)
def test_generate_token_explicit_username_overrides_default():
"""An explicitly provided username should take precedence over the full name."""
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user, username="Custom Name")
claims = decode_token(token)
assert claims["name"] == "Custom Name"
def test_authenticated_username_ignored_when_editing_disabled(settings):
"""With editing disabled, an authenticated user's username is ignored."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user, username="Custom Name")
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
"""Disabling editing doesn't disturb the default full-name path."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
def test_anonymous_uses_username_when_provided():
"""An anonymous user's provided username is used as the display name."""
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
claims = decode_token(token)
assert claims["name"] == "Guest42"
def test_anonymous_username_used_even_when_editing_disabled(settings):
"""The setting governs authenticated users only; anonymous can still set a name."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
claims = decode_token(token)
assert claims["name"] == "Guest42"
def test_anonymous_falls_back_to_anonymous_label():
"""With no username, an anonymous user is labelled 'Anonymous'."""
token = generate_token(room="my-room", user=AnonymousUser())
claims = decode_token(token)
assert claims["name"] == "Anonymous"
@mock.patch("asyncio.get_running_loop")
+7 -2
View File
@@ -109,11 +109,16 @@ def generate_token(
default_username = "Anonymous"
else:
identity = str(user.sub)
default_username = str(user)
default_username = user.full_name or str(user)
if color is None:
color = generate_color(identity)
can_edit = (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
)
display_name = (username or default_username) if can_edit else default_username
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -121,7 +126,7 @@ def generate_token(
)
.with_grants(video_grants)
.with_identity(identity)
.with_name(username or default_username)
.with_name(display_name)
.with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
+5
View File
@@ -671,6 +671,11 @@ class Base(Configuration):
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
environ_prefix=None,
)
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
True,
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
environ_prefix=None,
)
# Recording settings
RECORDING_ENABLE = values.BooleanValue(
+1
View File
@@ -58,6 +58,7 @@ export interface ApiConfig {
transcription_destination?: string
max_participants_for_sound: number
auto_mute_on_join_threshold: number
authenticated_users_can_edit_display_name: boolean
}
const fetchConfig = (): Promise<ApiConfig> => {
@@ -9,10 +9,11 @@ import { useState } from 'react'
import { menuRecipe } from '@/primitives/menuRecipe'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { loadUserChoices } from '@livekit/components-core'
import { useSnapshot } from 'valtio'
import { userStore } from '@/stores/user'
export const CreateMeetingMenu = () => {
const { username } = loadUserChoices()
const { username } = useSnapshot(userStore)
const { t } = useTranslation('home')
const { mutateAsync: createRoom } = useCreateRoom()
@@ -22,7 +22,7 @@ export type ApiRoom = {
id: string
name: string
slug: string
pin_code: string
pin_code?: string
is_administrable: boolean
access_level: ApiAccessLevel
livekit?: ApiLiveKit
@@ -36,6 +36,7 @@ import { PictureInPictureConference } from '@/features/pip/components/PictureInP
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user'
export const Conference = ({
roomId,
@@ -53,6 +54,8 @@ export const Conference = ({
userChoices: LocalUserChoices
}
const { username } = useSnapshot(userStore)
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId, posthog])
@@ -83,10 +86,10 @@ export const Conference = ({
queryFn: () =>
fetchRoom({
roomId: roomId as string,
username: userConfig.username,
username: username,
}).catch((error) => {
if (error.statusCode == '404') {
createRoom({ slug: roomId, username: userConfig.username })
createRoom({ slug: roomId, username })
}
}),
retry: false,
@@ -42,14 +42,17 @@ import {
saveAudioInputDeviceId,
saveAudioInputEnabled,
saveAudioOutputDeviceId,
saveUsername,
saveVideoInputDeviceId,
saveVideoInputEnabled,
userChoicesStore,
} from '@/stores/userChoices'
import { saveUsername, userStore } from '@/stores/user'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
import { useSnapshot } from 'valtio'
import { useUser } from '@/features/auth/api/useUser'
import { useConfig } from '@/api/useConfig'
const onError = (e: Error) => console.error('ERROR', e)
@@ -114,6 +117,9 @@ export const Join = ({
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { data: configData } = useConfig()
const { isLoggedIn, user } = useUser()
const {
audioEnabled,
videoEnabled,
@@ -121,9 +127,10 @@ export const Join = ({
audioOutputDeviceId,
videoDeviceId,
processorConfig,
username,
} = useSnapshot(userChoicesStore)
const { username } = useSnapshot(userStore)
const initialUserChoices = useRef<LocalUserChoices | null>(null)
if (initialUserChoices.current === null) {
@@ -134,7 +141,6 @@ export const Join = ({
audioOutputDeviceId,
videoDeviceId,
processorConfig,
username,
}
}
@@ -448,20 +454,23 @@ export const Join = ({
<H lvl={1} margin="sm" centered>
{t('heading')}
</H>
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
{(!isLoggedIn ||
configData?.authenticated_users_can_edit_display_name) && (
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
defaultValue={username || user?.full_name}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
)}
</VStack>
</Form>
)
@@ -2,6 +2,7 @@ import type { Participant } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar'
import { useIsSpeaking } from '@livekit/components-react'
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useMemo, useRef } from 'react'
@@ -10,7 +11,6 @@ const StyledParticipantPlaceHolder = styled('div', {
base: {
width: '100%',
height: '100%',
backgroundColor: 'primaryDark.100',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
@@ -26,6 +26,10 @@ export const ParticipantPlaceholder = ({
}: ParticipantPlaceholderProps) => {
const isSpeaking = useIsSpeaking(participant)
const participantColor = getParticipantColor(participant)
const backgroundGradient = useMemo(
() => getParticipantBackgroundGradient(participantColor),
[participantColor]
)
const placeholderEl = useRef<HTMLDivElement>(null)
const { width, height } = useSize(placeholderEl)
@@ -39,7 +43,13 @@ export const ParticipantPlaceholder = ({
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
return (
<StyledParticipantPlaceHolder ref={placeholderEl}>
<StyledParticipantPlaceHolder
ref={placeholderEl}
style={{
backgroundColor: participantColor,
backgroundImage: backgroundGradient,
}}
>
<div
style={{
borderRadius: '50%',
@@ -0,0 +1,12 @@
/**
* Builds a radial gradient from the participant's avatar color:
* brighter in the center (where the avatar sits), darker at the edges.
* Mixed in oklch so the color stays vivid as it darkens instead of greying out.
*/
export const getParticipantBackgroundGradient = (color: string): string =>
`radial-gradient(circle at 50% 45%,
color-mix(in oklch, ${color} 82%, white) 0%,
color-mix(in oklch, ${color} 90%, white) 8%,
${color} 35%,
color-mix(in oklch, ${color} 85%, black) 65%,
color-mix(in oklch, ${color} 65%, black) 100%)`
@@ -8,8 +8,9 @@ import { HStack } from '@/styled-system/jsx'
import { useState } from 'react'
import { LoginButton } from '@/components/LoginButton'
import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
import { saveUsername } from '@/stores/userChoices'
import { saveUsername } from '@/stores/user'
import { logout } from '@/features/auth/utils/logout'
import { useConfig } from '@/api/useConfig'
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -17,6 +18,7 @@ export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
const { t } = useTranslation('settings')
const room = useRoomContext()
const { data } = useConfig()
const { user, isLoggedIn } = useUser()
const { renameParticipant } = useRenameParticipant()
@@ -45,15 +47,17 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('account.heading')}</H>
<Field
type="text"
label={t('account.nameLabel')}
value={name}
onChange={setName}
validate={(value) => {
return !value ? <p>{t('account.nameError')}</p> : null
}}
/>
{(!isLoggedIn || data?.authenticated_users_can_edit_display_name) && (
<Field
type="text"
label={t('account.nameLabel')}
value={name}
onChange={setName}
validate={(value) => {
return !value ? <p>{t('account.nameError')}</p> : null
}}
/>
)}
<H lvl={2}>{t('account.authentication')}</H>
{isLoggedIn ? (
<>
+37
View File
@@ -0,0 +1,37 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
type State = {
username: string
}
const DEFAULT_STATE = {
username: '',
}
function getUserState(): State {
try {
const stored = localStorage.getItem(STORAGE_KEYS.USER)
if (!stored) return DEFAULT_STATE
const parsed = JSON.parse(stored)
return {
...parsed,
}
} catch (error: unknown) {
console.error(
'[UserPreferencesStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
export const userStore = proxy<State>(getUserState())
subscribe(userStore, () => {
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userStore))
})
export const saveUsername = (username: string) => {
userStore.username = username
}
+8 -6
View File
@@ -12,7 +12,7 @@ import { VideoQuality } from 'livekit-client'
export type VideoResolution = 'h720' | 'h360' | 'h180'
export type LocalUserChoices = LocalUserChoicesLK & {
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
processorConfig?: ProcessorConfig
noiseReductionEnabled?: boolean
audioOutputDeviceId?: string
@@ -32,7 +32,13 @@ function getUserChoicesState(): LocalUserChoices {
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
subscribe(userChoicesStore, () => {
saveUserChoices(userChoicesStore, false)
// TEMPORARY: cast needed because our store omits `username`, which we no
// longer persis in this store, while LiveKit's `saveUserChoices` still expects the full
// `LocalUserChoices` shape. `username` ends up `undefined` in the saved
// object, which `saveUserChoices` tolerates at runtime.
// We are migrating away from LiveKit's persistence logic to our own store
// handling for more control — this cast can be removed once that lands.
saveUserChoices(userChoicesStore as LocalUserChoicesLK, false)
})
// we run some logic on store loading to check if the processor config is still valid
@@ -90,10 +96,6 @@ export const saveVideoSubscribeQuality = (quality: VideoQuality) => {
userChoicesStore.videoSubscribeQuality = quality
}
export const saveUsername = (username: string) => {
userChoicesStore.username = username
}
export const saveNoiseReductionEnabled = (enabled: boolean) => {
userChoicesStore.noiseReductionEnabled = enabled
}
+1
View File
@@ -4,5 +4,6 @@
export const STORAGE_KEYS = {
NOTIFICATIONS: 'app_notification_settings',
USER_PREFERENCES: 'app_user_preferences',
USER: 'app_user',
ACCESSIBILITY: 'app_accessibility_settings',
} as const
+2
View File
@@ -191,6 +191,8 @@ backend:
SUMMARY_SERVICE_API_TOKEN: password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
OIDC_USERINFO_FULLNAME_FIELDS: first_name, last_name
OIDC_USERINFO_SHORTNAME_FIELD: first_name
migrate:
command: