Compare commits

..

5 Commits

Author SHA1 Message Date
lebaudantoine 1ce80cb57e 🩹(doc) fix wrong endpoint path
Applications to application in the application/token endpoint.
Spotted by external contributor.
2025-10-22 05:01:41 +02:00
Ghislain LE MEUR 59d4c2583b 🐛(auth) fix LiveKit token authentication field mismatch
Fixes "Invalid LiveKit token" errors caused by field mismatch between
token generation and authentication lookup.

Previously:
- generate_token() used user.sub as token identity
- LiveKitTokenAuthentication tried to retrieve user via user.id field
- This failed when sub was not a UUID (e.g., from LemonLDAP OIDC provider)

Now:
- generate_token() continues using user.sub (canonical OIDC identifier)
- LiveKitTokenAuthentication correctly looks up by sub field
- Both sides now consistently use the same field

This ensures compatibility with all RFC 7519-compliant OIDC providers,
regardless of their sub claim format.
2025-10-20 04:57:02 +02:00
Ghislain LE MEUR 4b80b4ac9f 🔖(helm) release chart 0.0.14
Fix missing image and command attributes for celery workers
2025-10-17 12:18:31 +02:00
Ghislain LE MEUR 96d7a8875b 🐛(helm) add default commands for celery workers
Without explicit commands in values.yaml,
celeryTranscribe and  celerySummarize pods
were using the Dockerfile's default CMD (uvicorn),
which started the REST API instead of Celery workers.

This fix adds default commands to values.yaml for both services,
ensuring they run as Celery workers processing their respective
queues (transcribe-queue and summarize-queue).
2025-10-17 12:18:31 +02:00
Ghislain LE MEUR dc177b69d8 🐛(summary) add image
Add missing image attributes for summary, celerySummarize and celeryTranscribe
2025-10-17 12:18:31 +02:00
26 changed files with 68 additions and 358 deletions
+3 -3
View File
@@ -7,7 +7,7 @@ info:
#### Authentication Flow
1. Exchange application credentials for a JWT token via `/external-api/v1.0/applications/token`.
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token`.
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
3. Tokens are scoped and allow applications to act on behalf of specific users.
@@ -40,7 +40,7 @@ tags:
description: Room management operations
paths:
/applications/token:
/application/token:
post:
tags:
- Authentication
@@ -283,7 +283,7 @@ components:
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/applications/token` endpoint.
JWT token obtained from the `/application/token` endpoint.
Include in requests as: `Authorization: Bearer <token>`
schemas:
+1 -1
View File
@@ -30,7 +30,7 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
raise exceptions.AuthenticationFailed("Token missing user identity")
try:
user = UserModel.objects.get(id=user_id)
user = UserModel.objects.get(sub=user_id)
except UserModel.DoesNotExist:
user = AnonymousUser()
-5
View File
@@ -326,11 +326,6 @@ class Base(Configuration):
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGIN_ENABLED", environ_prefix=None
),
"idle_disconnect_warning_delay": values.PositiveIntegerValue(
None,
environ_name="FRONTEND_IDLE_DISCONNECT_WARNING_DELAY",
environ_prefix=None,
),
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
-1
View File
@@ -25,7 +25,6 @@ export interface ApiConfig {
custom_css_url?: string
use_french_gov_footer?: boolean
use_proconnect_button?: boolean
idle_disconnect_warning_delay?: number
recording?: {
is_enabled?: boolean
available_modes?: RecordingMode[]
@@ -1,102 +0,0 @@
import { Button, Dialog, H, P } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
export const IsIdleDisconnectModal = () => {
const connectionObserverSnap = useSnapshot(connectionObserverStore)
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
useEffect(() => {
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
setTimeRemaining(IDLE_DISCONNECT_TIMEOUT_MS)
const interval = setInterval(() => {
setTimeRemaining((prev) => {
if (prev <= 1000) {
clearInterval(interval)
connectionObserverStore.isIdleDisconnectModalOpen = false
navigateTo('feedback', { duplicateIdentity: false })
return 0
}
return prev - 1000
})
}, 1000)
return () => clearInterval(interval)
}
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
const minutes = Math.floor(timeRemaining / 1000 / 60)
const seconds = (timeRemaining / 1000) % 60
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
return (
<Dialog
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
role="alertdialog"
type="alert"
aria-label={t('title')}
onClose={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
}}
>
{({ close }) => {
return (
<div>
<div
className={css({
height: '50px',
width: '50px',
backgroundColor: 'blue.100',
borderRadius: '25px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontWeight: '500',
color: 'blue.800',
margin: 'auto',
})}
>
{formattedTime}
</div>
<H lvl={2} centered>
{t('title')}
</H>
<P>
{t('body', {
duration: humanizeDuration(IDLE_DISCONNECT_TIMEOUT_MS, {
language: i18n.language,
}),
})}
</P>
<P>{t('settings')}</P>
<HStack marginTop="2rem">
<Button
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
navigateTo('feedback', { duplicateIdentity: false })
}}
size="sm"
variant="secondary"
>
{t('leaveButton')}
</Button>
<Button onPress={close} size="sm" variant="primary">
{t('stayButton')}
</Button>
</HStack>
</div>
)
}}
</Dialog>
)
}
@@ -1,72 +1,15 @@
import {
useRemoteParticipants,
useRoomContext,
} from '@livekit/components-react'
import { useRoomContext } from '@livekit/components-react'
import { useEffect, useRef } from 'react'
import { DisconnectReason, RoomEvent } from 'livekit-client'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import posthog from 'posthog-js'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { useConfig } from '@/api/useConfig'
import { userPreferencesStore } from '@/stores/userPreferences'
import { useSnapshot } from 'valtio'
export const useConnectionObserver = () => {
const room = useRoomContext()
const connectionStartTimeRef = useRef<number | null>(null)
const { data } = useConfig()
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const userPreferencesSnap = useSnapshot(userPreferencesStore)
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const remoteParticipants = useRemoteParticipants({
updateOnlyOn: [
RoomEvent.ParticipantConnected,
RoomEvent.ParticipantDisconnected,
],
})
useEffect(() => {
// Always clear existing timer on dependency change
if (idleDisconnectModalTimeoutRef.current) {
clearTimeout(idleDisconnectModalTimeoutRef.current)
idleDisconnectModalTimeoutRef.current = null
}
const isEnabled = userPreferencesSnap.is_idle_disconnect_modal_enabled
const delay = data?.idle_disconnect_warning_delay
// Disabled or invalid delay: ensure modal is closed
if (!isEnabled || !delay) {
connectionObserverStore.isIdleDisconnectModalOpen = false
return
}
if (remoteParticipants.length === 0) {
idleDisconnectModalTimeoutRef.current = setTimeout(() => {
connectionObserverStore.isIdleDisconnectModalOpen = true
}, delay)
} else {
connectionObserverStore.isIdleDisconnectModalOpen = false
}
return () => {
if (idleDisconnectModalTimeoutRef.current) {
clearTimeout(idleDisconnectModalTimeoutRef.current)
idleDisconnectModalTimeoutRef.current = null
}
}
}, [
remoteParticipants.length,
data?.idle_disconnect_warning_delay,
userPreferencesSnap.is_idle_disconnect_modal_enabled,
])
useEffect(() => {
if (!isAnalyticsEnabled) return
@@ -36,7 +36,6 @@ import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
import { Subtitles } from '@/features/subtitle/component/Subtitles'
import { CarouselLayout } from '../components/layout/CarouselLayout'
import { GridLayout } from '../components/layout/GridLayout'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
const LayoutWrapper = styled(
'div',
@@ -192,7 +191,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<div
// todo - extract these magic values into constant
style={{
@@ -2,8 +2,6 @@ import { Field, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { userPreferencesStore } from '@/stores/userPreferences'
import { useSnapshot } from 'valtio'
export type GeneralTabProps = Pick<TabPanelProps, 'id'>
@@ -11,8 +9,6 @@ export const GeneralTab = ({ id }: GeneralTabProps) => {
const { t, i18n } = useTranslation('settings')
const { languagesList, currentLanguage } = useLanguageLabels()
const userPreferencesSnap = useSnapshot(userPreferencesStore)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('language.heading')}</H>
@@ -25,20 +21,6 @@ export const GeneralTab = ({ id }: GeneralTabProps) => {
i18n.changeLanguage(lang as string)
}}
/>
<H lvl={2}>{t('preferences.title')}</H>
<Field
type="switch"
label={t('preferences.idleDisconnectModal.label')}
description={t('preferences.idleDisconnectModal.description')}
isSelected={userPreferencesSnap.is_idle_disconnect_modal_enabled}
onChange={(value) =>
(userPreferencesStore.is_idle_disconnect_modal_enabled = value)
}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</TabPanel>
)
}
@@ -1,5 +1,5 @@
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { Field, H } from '@/primitives'
import { H, Switch } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
@@ -23,19 +23,15 @@ export const NotificationsTab = ({ id }: NotificationsTabProps) => {
{Array.from(notificationsSnap.soundNotifications).map(
([key, value]) => (
<li key={key}>
<Field
type="switch"
aria-label={`${t(`actions.${value ? 'disable' : 'enable'}`)} ${t('label')} "${t(`items.${key}.label`)}"`}
label={t(`items.${key}.label`)}
<Switch
aria-label={`${t(`actions.${value ? 'disable' : 'enable'}`)} ${t('label')} "${t(`items.${key}`)}"`}
isSelected={value}
onChange={(v) => {
notificationsStore.soundNotifications.set(key, v)
}}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
>
{t(`items.${key}`)}
</Switch>
</li>
)
)}
-7
View File
@@ -149,13 +149,6 @@
"newTab": "Neues Fenster"
}
},
"isIdleDisconnectModal": {
"title": "Bist du noch da?",
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
"settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.",
"stayButton": "Gespräch fortsetzen",
"leaveButton": "Jetzt verlassen"
},
"controls": {
"microphone": "Mikrofon",
"camera": "Kamera",
+3 -16
View File
@@ -7,13 +7,6 @@
"authentication": "Authentifizierung",
"nameError": "Ihr Name darf nicht leer sein"
},
"preferences": {
"title": "Einstellungen",
"idleDisconnectModal": {
"label": "Anrufe ohne Teilnehmer verlassen",
"description": "Verlässt automatisch einen Anruf nach einigen Minuten, wenn kein anderer Teilnehmer beitritt"
}
},
"audio": {
"microphone": {
"heading": "Mikrofon",
@@ -76,15 +69,9 @@
"enable": "Aktivieren"
},
"items": {
"participantJoined": {
"label": "Teilnehmer beigetreten"
},
"handRaised": {
"label": "Hand gehoben"
},
"messageReceived": {
"label": "Nachricht erhalten"
}
"participantJoined": "Teilnehmer beigetreten",
"handRaised": "Hand gehoben",
"messageReceived": "Nachricht erhalten"
}
},
"dialog": {
-7
View File
@@ -149,13 +149,6 @@
"newTab": "New window"
}
},
"isIdleDisconnectModal": {
"title": "Are you still there?",
"body": "You are the only participant. This call will end in {{duration}}. Would you like to continue the call?",
"settings": "To stop seeing this message, go to Settings > General.",
"stayButton": "Continue the call",
"leaveButton": "Leave now"
},
"controls": {
"microphone": "Microphone",
"camera": "Camera",
+3 -16
View File
@@ -7,13 +7,6 @@
"authentication": "Authentication",
"nameError": "Your name cannot be empty"
},
"preferences": {
"title": "Preferences",
"idleDisconnectModal": {
"label": "Leave calls with no participants",
"description": "Automatically leaves a call after a few minutes if no other participant joins"
}
},
"audio": {
"microphone": {
"heading": "Microphone",
@@ -76,15 +69,9 @@
"enable": "Enable"
},
"items": {
"participantJoined": {
"label": "Participant joined"
},
"handRaised": {
"label": "Hand raised"
},
"messageReceived": {
"label": "Message received"
}
"participantJoined": "Participant joined",
"handRaised": "Hand raised",
"messageReceived": "Message received"
}
},
"dialog": {
-7
View File
@@ -149,13 +149,6 @@
"newTab": "Nouvelle fenêtre"
}
},
"isIdleDisconnectModal": {
"title": "Êtes-vous toujours là ?",
"body": "Vous êtes le seul participant. Cet appel va donc se terminer dans {{duration}}. Voulez-vous poursuivre l'appel ?",
"settings": "Pour ne plus voir ce message, accèder à Paramètres > Général.",
"stayButton": "Poursuivre l'appel",
"leaveButton": "Quitter maintenant"
},
"controls": {
"microphone": "Microphone",
"camera": "Camera",
+3 -16
View File
@@ -7,13 +7,6 @@
"authentication": "Authentification",
"nameError": "Votre Nom ne peut pas être vide"
},
"preferences": {
"title": "Préférences",
"idleDisconnectModal": {
"label": "Quitter les appels sans autre participant",
"description": "Vous fait quitter un appel au bout de quelques minutes si aucun autre participant ne vous rejoint"
}
},
"audio": {
"microphone": {
"heading": "Micro",
@@ -76,15 +69,9 @@
"enable": "Activer"
},
"items": {
"participantJoined": {
"label": "Un nouveau participant"
},
"handRaised": {
"label": "Une main levée"
},
"messageReceived": {
"label": "Un message reçu"
}
"participantJoined": "Un nouveau participant",
"handRaised": "Une main levée",
"messageReceived": "Un message reçu"
}
},
"dialog": {
-7
View File
@@ -149,13 +149,6 @@
"newTab": "Nieuw venster"
}
},
"isIdleDisconnectModal": {
"title": "Ben je er nog?",
"body": "Je bent de enige deelnemer. Dit gesprek wordt over {{duration}} beëindigd. Wil je het gesprek voortzetten?",
"settings": "Om dit bericht niet meer te zien, ga naar Instellingen > Algemeen.",
"stayButton": "Gesprek voortzetten",
"leaveButton": "Nu verlaten"
},
"controls": {
"microphone": "Microfoon",
"camera": "Camera",
+3 -16
View File
@@ -7,13 +7,6 @@
"authentication": "Authenticatie",
"nameError": "Uw naam mag niet leeg zijn"
},
"preferences": {
"title": "Voorkeuren",
"idleDisconnectModal": {
"label": "Verlaat oproepen zonder deelnemers",
"description": "Verlaat automatisch een oproep na een paar minuten als geen andere deelnemer meedoet"
}
},
"audio": {
"microphone": {
"heading": "Microfoon",
@@ -76,15 +69,9 @@
"enable": "Inschakelen"
},
"items": {
"participantJoined": {
"label": "Deelnemer is toegevoegd"
},
"handRaised": {
"label": "Hand opgestoken"
},
"messageReceived": {
"label": "Bericht ontvangen"
}
"participantJoined": "Deelnemer is toegevoegd",
"handRaised": "Hand opgestoken",
"messageReceived": "Bericht ontvangen"
}
},
"dialog": {
-4
View File
@@ -28,10 +28,6 @@ const box = cva({
width: '30rem',
maxWidth: '100%',
},
alert: {
width: '24rem',
maxWidth: '100%',
},
},
variant: {
light: {
+2 -7
View File
@@ -80,7 +80,7 @@ export type DialogProps = RACDialogProps & {
* after user interaction
*/
onOpenChange?: (isOpen: boolean) => void
type?: 'flex' | 'alert'
type?: 'flex'
innerRef?: MutableRefObject<HTMLDivElement | null>
size?: 'full' | 'large'
}
@@ -96,12 +96,7 @@ export const Dialog = ({
...dialogProps
}: DialogProps) => {
const isAlert = dialogProps['role'] === 'alertdialog'
const boxType =
dialogProps['type'] === 'alert'
? 'alert'
: dialogProps['type'] !== 'flex'
? 'dialog'
: undefined
const boxType = dialogProps['type'] !== 'flex' ? 'dialog' : undefined
return (
<StyledModalOverlay
isKeyboardDismissDisabled={isAlert}
+1 -3
View File
@@ -259,7 +259,6 @@ export const Field = <T extends object>({
}
if (type === 'switch') {
const SWITCH_COMPONENT_WIDTH = '41px'
return (
<FieldWrapper {...props.wrapperProps}>
<div
@@ -274,11 +273,10 @@ export const Field = <T extends object>({
{description && (
<Text
variant="note"
wrap={'balance'}
wrap={'pretty'}
className={css({
textStyle: 'sm',
marginBottom: '0.5rem',
marginRight: SWITCH_COMPONENT_WIDTH,
})}
>
{description}
@@ -1,9 +0,0 @@
import { proxy } from 'valtio'
type State = {
isIdleDisconnectModalOpen: boolean
}
export const connectionObserverStore = proxy<State>({
isIdleDisconnectModalOpen: false,
})
@@ -1,38 +0,0 @@
import { proxy } from 'valtio'
import { subscribe } from 'valtio/index'
import { STORAGE_KEYS } from '@/utils/storageKeys'
type State = {
is_idle_disconnect_modal_enabled: boolean
}
const DEFAULT_STATE = {
is_idle_disconnect_modal_enabled: true,
}
function getUserPreferencesState(): State {
try {
const stored = localStorage.getItem(STORAGE_KEYS.USER_PREFERENCES)
if (!stored) return DEFAULT_STATE
const parsed = JSON.parse(stored)
return {
...DEFAULT_STATE,
...parsed,
}
} catch (error: unknown) {
console.error(
'[UserPreferencesStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
export const userPreferencesStore = proxy<State>(getUserPreferencesState())
subscribe(userPreferencesStore, () => {
localStorage.setItem(
STORAGE_KEYS.USER_PREFERENCES,
JSON.stringify(userPreferencesStore)
)
})
-1
View File
@@ -3,5 +3,4 @@
*/
export const STORAGE_KEYS = {
NOTIFICATIONS: 'app_notification_settings',
USER_PREFERENCES: 'app_user_preferences',
} as const
@@ -57,7 +57,6 @@ backend:
FRONTEND_TRANSCRIPT: "{'form_beta_users': 'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'}"
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.13
version: 0.0.14
+41 -2
View File
@@ -428,6 +428,13 @@ posthog:
## @section summary
summary:
## @param summary.image.repository Repository to use to pull meet's summary container image
## @param summary.image.tag meet's summary container tag
## @param summary.image.pullPolicy summary container image pull policy
image:
repository: lasuite/meet-summary
pullPolicy: IfNotPresent
tag: "latest"
## @param summary.dpAnnotations Annotations to add to the summary Deployment
dpAnnotations: {}
@@ -527,11 +534,27 @@ summary:
## @section celeryTranscribe
celeryTranscribe:
## @param celeryTranscribe.image.repository Repository to use to pull meet's celeryTranscribe container image
## @param celeryTranscribe.image.tag meet's celeryTranscribe container tag
## @param celeryTranscribe.image.pullPolicy celeryTranscribe container image pull policy
image:
repository: lasuite/meet-summary
pullPolicy: IfNotPresent
tag: "latest"
## @param celeryTranscribe.dpAnnotations Annotations to add to the celeryTranscribe Deployment
dpAnnotations: {}
## @param celeryTranscribe.command Override the celeryTranscribe container command
command: []
command:
- "celery"
- "-A"
- "summary.core.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "transcribe-queue"
## @param celeryTranscribe.args Override the celeryTranscribe container args
args: []
@@ -620,11 +643,27 @@ celeryTranscribe:
## @section celerySummarize
celerySummarize:
## @param celerySummarize.image.repository Repository to use to pull meet's celerySummarize container image
## @param celerySummarize.image.tag meet's celerySummarize container tag
## @param celerySummarize.image.pullPolicy celerySummarize container image pull policy
image:
repository: lasuite/meet-summary
pullPolicy: IfNotPresent
tag: "latest"
## @param celerySummarize.dpAnnotations Annotations to add to the celerySummarize Deployment
dpAnnotations: {}
## @param celerySummarize.command Override the celerySummarize container command
command: []
command:
- "celery"
- "-A"
- "summary.core.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "summarize-queue"
## @param celerySummarize.args Override the celerySummarize container args
args: []