Compare commits

...

5 Commits

Author SHA1 Message Date
lebaudantoine 9635fb972e ️(frontend) defer loading the Crisp script until idle
Load the Crisp JavaScript module only once the frontend is idle,
instead of during the initial page load.

Keeps the critical path lighter and prevents Crisp from competing
with the app's own bootstrap for network and CPU on slow devices.
2026-09-10 23:22:48 +02:00
lebaudantoine 04fd79b56b 🔒️(backend) reject inactive users in resource server backend
The resource server backend returned any user matching the token's
`sub` claim without checking `User.is_active`. The upstream lasuite
backend only validates the token's introspection `active` claim, so a
deactivated Django account kept API access until its token expired.

Raise `SuspiciousOperation` in `get_or_create_user` when the user is
inactive, which the authentication class turns into a 401, consistent
with `BaseJWTAuthentication`. Add unit and end-to-end tests.
2026-09-10 11:10:53 +02:00
leo e336122cfa 💬(frontend) clarify video recording wording
Video recording from transcription panel did not explicitly
mention video, leading to confusion from some users. Make
wording more explicit.
2026-09-09 20:03:36 +02:00
lebaudantoine 172dc70649 (backend) allow configuring trace sampling
Add a configuration knob for the trace sampling rate, so we can
enable tracing on middleware and cache spans when debugging slow
requests in production.

Sampling is set to 0 by default, so tracing stays fully off unless
explicitly enabled.
2026-09-09 20:02:57 +02:00
lebaudantoine e0ab7f191f 📈(frontend) include LiveKit SIDs in the connection analytics event
Attach the LiveKit SIDs (room and participant) to the connection
analytics event.

Makes it easier to debug problematic sessions and to correlate a
room session with the corresponding LiveKit logs.
2026-09-09 13:38:44 +02:00
12 changed files with 229 additions and 37 deletions
+8 -5
View File
@@ -8,15 +8,18 @@ and this project adheres to
## [Unreleased]
### Changed
- 📈(frontend) include LiveKit SIDs in the connection analytics event
- 🔇(backend) silence expected 401 warnings on /me
- 🔇(backend) silence noisy request summary info logs
- ⚡️(frontend) defer loading the Crisp script until idle
### Fixed
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
- 🔒️(backend) enforce display name setting on rename API
### Changed
- 🔇(backend) silence expected 401 warnings on /me
- 🔇(backend) silence noisy request summary info logs
- 🔒️(backend) reject inactive users in resource server backend
## [1.31.0] - 2026-09-08
@@ -286,6 +286,10 @@ class ResourceServerBackend(LaSuiteBackend):
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
def create_user(self, sub):
@@ -0,0 +1,96 @@
"""Tests for the external API ResourceServerBackend."""
from django.core.exceptions import SuspiciousOperation
import pytest
import responses
from rest_framework.test import APIClient
from core.external_api.authentication import ResourceServerBackend
from core.factories import UserFactory
from core.models import User
pytestmark = pytest.mark.django_db
def _payload(sub):
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
def test_resource_server_backend_get_or_create_user_active():
"""An existing active user matching the sub should be returned."""
user = UserFactory()
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
assert result == user
def test_resource_server_backend_get_or_create_user_inactive():
"""An inactive user should be rejected even with a valid token."""
user = UserFactory(is_active=False)
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
def test_resource_server_backend_get_or_create_user_creates(settings):
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
settings.OIDC_CREATE_USER = True
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result.sub == "new-sub"
assert result.is_active is True
assert User.objects.filter(sub="new-sub").exists()
def test_resource_server_backend_get_or_create_user_no_creation(settings):
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result is None
assert not User.objects.filter(sub="new-sub").exists()
@responses.activate
def test_api_rooms_list_resource_server_inactive_user(settings):
"""End to end: a valid introspected token for an inactive user should get 401."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
user = UserFactory(is_active=False)
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": True,
"sub": user.sub,
"scope": "openid lasuite_meet rooms:list",
"client_id": "app",
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "login failed" in str(response.data).lower()
+11 -1
View File
@@ -469,6 +469,9 @@ class Base(Configuration):
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_TRACES_SAMPLE_RATE = values.FloatValue(
0.0, environ_name="SENTRY_TRACES_SAMPLE_RATE", environ_prefix=None
)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -1230,7 +1233,14 @@ class Base(Configuration):
dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(), # build, test, development, production
release=get_release(),
integrations=[DjangoIntegration()],
traces_sample_rate=cls.SENTRY_TRACES_SAMPLE_RATE,
integrations=[
DjangoIntegration(
transaction_style="url",
middleware_spans=True,
cache_spans=True,
)
],
)
sentry_sdk.set_tag("application", "backend")
@@ -71,20 +71,30 @@ export const ConnectionObserver = () => {
useEffect(() => {
if (!isAnalyticsEnabled) return
const handleConnection = () => {
const handleConnection = async () => {
// Preserve original connection timestamp across reconnections to measure
// total session duration from first connect to final disconnect.
if (connectionStartTimeRef.current != null) return
connectionStartTimeRef.current = Date.now()
void captureMediaEvent('connection-event', {})
const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
void captureMediaEvent('connection-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
}
const handleReconnect = () => {
captureEvent('reconnect-event')
}
const handleReconnected = () => {
captureEvent('reconnected-event')
const handleReconnected = async () => {
const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
captureEvent('reconnected-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
}
const handleSignalingConnect = () => {
@@ -2,23 +2,23 @@ import { RiQuestionLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { Crisp } from 'crisp-sdk-web'
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
import {
useIsSupportEnabled,
openSupportChat,
} from '@/features/support/hooks/useSupport'
export const SupportMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const isSupportEnabled = useIsSupportEnabled()
if (!isSupportEnabled || !Crisp) {
if (!isSupportEnabled) {
return
}
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => {
Crisp?.chat.open()
}}
onAction={openSupportChat}
>
<RiQuestionLine size={20} />
{t('support')}
@@ -1,20 +1,45 @@
import { useEffect } from 'react'
import { Crisp } from 'crisp-sdk-web'
import { useEffect, useState } from 'react'
import { type ApiUser } from '@/features/auth/api/ApiUser'
import { useUser } from '@/features/auth/api/useUser'
import { useConfig } from '@/api/useConfig'
type CrispSdk = (typeof import('crisp-sdk-web'))['Crisp']
let crisp: CrispSdk | undefined
let crispPromise: Promise<CrispSdk> | undefined
const loadCrisp = (): Promise<CrispSdk> => {
crispPromise ??= import('crisp-sdk-web')
.then((module) => {
crisp = module.Crisp
return module.Crisp
})
.catch((error) => {
crispPromise = undefined
throw error
})
return crispPromise
}
export const openSupportChat = () => {
if (!crisp?.isCrispInjected()) return
crisp.chat.open()
}
export const initializeSupportSession = (user: ApiUser) => {
if (!Crisp.isCrispInjected()) return
if (!crisp?.isCrispInjected()) return
const { id, email } = user
Crisp.setTokenId(`meet-${id}`)
if (email) Crisp.user.setEmail(email)
crisp.setTokenId(`meet-${id}`)
if (email) crisp.user.setEmail(email)
}
export const terminateSupportSession = () => {
if (!Crisp.isCrispInjected()) return
Crisp.setTokenId()
Crisp.session.reset()
if (!crisp?.isCrispInjected()) return
crisp.setTokenId()
crisp.session.reset()
}
export type useSupportProps = {
@@ -22,26 +47,70 @@ export type useSupportProps = {
isDisabled?: boolean
}
// Configure Crisp chat for real-time support across all pages.
const IDLE_TIMEOUT_MS = 10_000
const scheduleWhenIdle = (callback: () => void): (() => void) => {
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(callback, {
timeout: IDLE_TIMEOUT_MS,
})
return () => window.cancelIdleCallback(handle)
}
const handle = window.setTimeout(callback, 1)
return () => window.clearTimeout(handle)
}
export const useSupport = ({ id, isDisabled }: useSupportProps) => {
const { user } = useUser()
const [isInjected, setIsInjected] = useState(
() => crisp?.isCrispInjected() ?? false
)
useEffect(() => {
if (!id || Crisp.isCrispInjected() || isDisabled) return
Crisp.configure(id)
Crisp.setHideOnMobile(true)
if (!id || isDisabled) return
if (crisp?.isCrispInjected()) {
setIsInjected(true)
return
}
let cancelled = false
const cancelIdle = scheduleWhenIdle(() => {
void loadCrisp()
.then((sdk) => {
if (cancelled) return
if (!sdk.isCrispInjected()) {
sdk.configure(id)
sdk.setHideOnMobile(true)
}
setIsInjected(true)
})
.catch((error) => {
if (!cancelled) {
console.error('Failed to initialize support chat', error)
}
})
})
return () => {
cancelled = true
cancelIdle()
}
}, [id, isDisabled])
useEffect(() => {
if (!user) return
if (!user || !isInjected || isDisabled) return
initializeSupportSession(user)
}, [user])
}, [user, isInjected, isDisabled])
return null
}
// Some users may block Crisp chat widget with browser ad blockers or anti-tracking plugins
// So we need to safely check if Crisp is available and not blocked
// Some users block the chat widget, so check its availability safely.
const isCrispAvailable = () => {
try {
return !!window?.$crisp?.is
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Ein neues Dokument wird erstellt auf",
"destinationUnknown": "Ein neues Dokument wird erstellt",
"language": "Meeting-Sprache:",
"recording": "Auch eine Aufzeichnung starten"
"recording": "Auch eine Videoaufzeichnung starten"
},
"button": {
"start": "Meeting-Transkription starten",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "A new document will be created on",
"destinationUnknown": "A new document will be created",
"language": "Meeting language:",
"recording": "Also start a recording"
"recording": "Also start a video recording"
},
"button": {
"start": "Start transcribing the meeting",
+1 -1
View File
@@ -479,7 +479,7 @@
"destination": "Se creará un nuevo documento en",
"destinationUnknown": "Se creará un nuevo documento",
"language": "Idioma de la reunión:",
"recording": "Iniciar también una grabación"
"recording": "Iniciar también una grabación de vídeo"
},
"button": {
"start": "Empezar a transcribir la reunión",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Un nouveau document sera créé sur",
"destinationUnknown": "Un nouveau document sera créé",
"language": "Langue de la réunion :",
"recording": "Démarrer aussi un enregistrement"
"recording": "Démarrer aussi un enregistrement vidéo"
},
"button": {
"start": "Commencer à transcrire la réunion",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Er wordt een nieuw document aangemaakt op",
"destinationUnknown": "Een nieuw document wordt aangemaakt",
"language": "Vergadertalen:",
"recording": "Start ook een opname"
"recording": "Start ook een video-opname"
},
"button": {
"start": "Begin met het transcriberen van de vergadering",