diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json
index 92e50db1..67318399 100644
--- a/src/frontend/package-lock.json
+++ b/src/frontend/package-lock.json
@@ -17,6 +17,7 @@
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
+ "derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
@@ -5294,6 +5295,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/derive-valtio": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
+ "integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "valtio": ">=2.0.0-rc.0"
+ }
+ },
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
diff --git a/src/frontend/package.json b/src/frontend/package.json
index 27828e21..d3318a4b 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -22,6 +22,7 @@
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
+ "derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
diff --git a/src/frontend/public/assets/camera_mic_permission.svg b/src/frontend/public/assets/camera_mic_permission.svg
new file mode 100644
index 00000000..ea7af92f
--- /dev/null
+++ b/src/frontend/public/assets/camera_mic_permission.svg
@@ -0,0 +1,109 @@
+
+
diff --git a/src/frontend/src/features/rooms/components/Permissions.tsx b/src/frontend/src/features/rooms/components/Permissions.tsx
index cda2f7ef..63b9bb9b 100644
--- a/src/frontend/src/features/rooms/components/Permissions.tsx
+++ b/src/frontend/src/features/rooms/components/Permissions.tsx
@@ -1,4 +1,12 @@
import { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions'
+import { css } from '@/styled-system/css'
+import { Dialog, H } from '@/primitives'
+import { RiEqualizer2Line } from '@remixicon/react'
+import { useEffect, useMemo } from 'react'
+import { useSnapshot } from 'valtio'
+import { permissionsStore } from '@/stores/permissions'
+import { useTranslation } from 'react-i18next'
+import { injectIconIntoTranslation } from '@/utils/translation'
/**
* Singleton component - ensures permissions sync runs only once across the app.
@@ -6,6 +14,90 @@ import { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions'
* Multiple instances may cause unexpected behavior or performance issues.
*/
export const Permissions = () => {
+ const { t } = useTranslation('rooms', { keyPrefix: 'permissionErrorDialog' })
+
useWatchPermissions()
- return null
+
+ const permissions = useSnapshot(permissionsStore)
+
+ const permissionLabel = useMemo(() => {
+ if (permissions.isMicrophoneDenied && permissions.isCameraDenied) {
+ return 'cameraAndMicrophone'
+ } else if (permissions.isCameraDenied) {
+ return 'camera'
+ } else if (permissions.isMicrophoneDenied) {
+ return 'microphone'
+ } else {
+ return 'default'
+ }
+ }, [permissions])
+
+ const [descriptionBeforeIcon, descriptionAfterIcon] =
+ injectIconIntoTranslation(t('body.openMenu'))
+
+ useEffect(() => {
+ if (
+ permissions.isPermissionDialogOpen &&
+ permissions.isCameraGranted &&
+ permissions.isMicrophoneGranted
+ ) {
+ permissionsStore.isPermissionDialogOpen = false
+ }
+ }, [permissions])
+
+ const appTitle = `${import.meta.env.VITE_APP_TITLE}`
+
+ return (
+
+ )
}
diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json
index 0ad2ad5e..638c74e5 100644
--- a/src/frontend/src/locales/de/rooms.json
+++ b/src/frontend/src/locales/de/rooms.json
@@ -77,6 +77,23 @@
},
"close": "OK"
},
+ "permissionErrorDialog": {
+ "heading": {
+ "camera": "{{appTitle}} darf Ihre Kamera nicht verwenden",
+ "microphone": "{{appTitle}} darf Ihr Mikrofon nicht verwenden",
+ "cameraAndMicrophone": "{{appTitle}} darf weder Ihr Mikrofon noch Ihre Kamera verwenden",
+ "default": "{{appTitle}} hat keine Berechtigung für bestimmte Zugriffe"
+ },
+ "body": {
+ "openMenu": "Klicken Sie auf das Einstellungen-Symbol ICON_PLACEHOLDER in der Adressleiste Ihres Browsers",
+ "details": {
+ "camera": "Zugriff auf die Kamera erlauben",
+ "microphone": "Zugriff auf das Mikrofon erlauben",
+ "cameraAndMicrophone": "Zugriff auf Kamera und Mikrofon erlauben",
+ "default": "Aktivieren Sie die erforderlichen Berechtigungen."
+ }
+ }
+ },
"error": {
"createRoom": {
"heading": "Authentifizierung erforderlich",
diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json
index 4653c8ab..eb80028f 100644
--- a/src/frontend/src/locales/en/rooms.json
+++ b/src/frontend/src/locales/en/rooms.json
@@ -77,6 +77,23 @@
},
"close": "OK"
},
+ "permissionErrorDialog": {
+ "heading": {
+ "camera": "{{appTitle}} is not allowed to use your camera",
+ "microphone": "{{appTitle}} is not allowed to use your microphone",
+ "cameraAndMicrophone": "{{appTitle}} is not allowed to use your microphone or camera",
+ "default": "{{appTitle}} is not allowed to use certain permissions"
+ },
+ "body": {
+ "openMenu": "Click on the settings icon ICON_PLACEHOLDER in your browser’s address bar",
+ "details": {
+ "camera": "Allow access to the camera",
+ "microphone": "Allow access to the microphone",
+ "cameraAndMicrophone": "Allow access to the camera and microphone",
+ "default": "Enable the necessary permissions."
+ }
+ }
+ },
"error": {
"createRoom": {
"heading": "Authentication Required",
diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json
index 032ba0ae..74b65dde 100644
--- a/src/frontend/src/locales/fr/rooms.json
+++ b/src/frontend/src/locales/fr/rooms.json
@@ -77,6 +77,23 @@
},
"close": "OK"
},
+ "permissionErrorDialog": {
+ "heading": {
+ "camera": "{{appTitle}} n'est pas autorisé à utiliser votre caméra",
+ "microphone": "{{appTitle}} n'est pas autorisé à utiliser votre micro",
+ "cameraAndMicrophone": "{{appTitle}} n'est pas autorisé à utiliser votre micro ni votre caméra",
+ "default": "{{appTitle}} n'est pas autorisé à utiliser certaines fonctionnalités nécessaires."
+ },
+ "body": {
+ "openMenu": "Cliquez sur l'icône des paramètres ICON_PLACEHOLDER dans la barre d'adresse de votre navigateur",
+ "details": {
+ "camera": "Autorisez l'accès à la caméra",
+ "microphone": "Autorisez l'accès au microphone",
+ "cameraAndMicrophone": "Autorisez l'accès à la caméra et au microphone",
+ "default": "Activez les autorisations nécessaires."
+ }
+ }
+ },
"error": {
"createRoom": {
"heading": "Authentification requise",
diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json
index 5d64a045..ef65b2fa 100644
--- a/src/frontend/src/locales/nl/rooms.json
+++ b/src/frontend/src/locales/nl/rooms.json
@@ -77,6 +77,23 @@
},
"close": "OK"
},
+ "permissionErrorDialog": {
+ "heading": {
+ "camera": "{{appTitle}} mag uw camera niet gebruiken",
+ "microphone": "{{appTitle}} mag uw microfoon niet gebruiken",
+ "cameraAndMicrophone": "{{appTitle}} mag uw microfoon en camera niet gebruiken",
+ "default": "{{appTitle}} heeft geen toestemming voor bepaalde rechten"
+ },
+ "body": {
+ "openMenu": "Klik op het instellingenpictogram ICON_PLACEHOLDER in de adresbalk van uw browser",
+ "details": {
+ "camera": "Toegang tot de camera toestaan",
+ "microphone": "Toegang tot de microfoon toestaan",
+ "cameraAndMicrophone": "Toegang tot camera en microfoon toestaan",
+ "default": "Schakel de vereiste machtigingen in."
+ }
+ }
+ },
"error": {
"createRoom": {
"heading": "Verificatie vereist",
diff --git a/src/frontend/src/stores/permissions.ts b/src/frontend/src/stores/permissions.ts
index a91653ef..32641c11 100644
--- a/src/frontend/src/stores/permissions.ts
+++ b/src/frontend/src/stores/permissions.ts
@@ -1,4 +1,5 @@
import { proxy } from 'valtio'
+import { derive } from 'derive-valtio'
type PermissionState =
| undefined
@@ -11,10 +12,27 @@ type State = {
cameraPermission: PermissionState
microphonePermission: PermissionState
isLoading: boolean
+ isPermissionDialogOpen: boolean
}
export const permissionsStore = proxy({
cameraPermission: undefined,
microphonePermission: undefined,
isLoading: true,
+ isPermissionDialogOpen: false,
})
+
+derive(
+ {
+ isCameraGranted: (get) =>
+ get(permissionsStore).cameraPermission == 'granted',
+ isMicrophoneGranted: (get) =>
+ get(permissionsStore).microphonePermission == 'granted',
+ isCameraDenied: (get) => get(permissionsStore).cameraPermission == 'denied',
+ isMicrophoneDenied: (get) =>
+ get(permissionsStore).microphonePermission == 'denied',
+ },
+ {
+ proxy: permissionsStore,
+ }
+)
diff --git a/src/frontend/src/utils/translation.ts b/src/frontend/src/utils/translation.ts
new file mode 100644
index 00000000..fe060100
--- /dev/null
+++ b/src/frontend/src/utils/translation.ts
@@ -0,0 +1,14 @@
+/**
+ * FRAGILE: Splits translated text on placeholder to inject icons inline.
+ *
+ * Fragile because:
+ * - Relies on exact string matching - typos break it silently
+ * - Translators may accidentally modify/remove placeholders
+ * - No validation or error handling
+ */
+export const injectIconIntoTranslation = (
+ translation: string,
+ placeholder: string = 'ICON_PLACEHOLDER'
+) => {
+ return translation.split(placeholder)
+}