From db1909d785230d2945021b596e3b2bac52220dce Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:54:15 +0100 Subject: [PATCH] fix: resolve federated identity checks for server mute/deafen pipeline - The client now dynamically resolves the user's federated identity via `getMyUserIdForOrigin` when evaluating incoming `voice_server_muted` and `voice_server_deafened` events. Previously, the client incorrectly compared the remote event's federated `userId` against the local `authStore` home `userId`, causing federated users to silently drop restriction events. - Client-side mic/deafen toggles (`toggleMic`, `toggleDeafen`) now accurately evaluate the user's origin-specific ID against the restriction sets, preventing federated users from bypassing locks. - UI state selectors (`VoiceControlBar`, `ChannelSidebar`) now compute `myOriginId` to correctly render the yellow server-lockdown indicators for cross-instance users. --- .../src/components/layout/ChannelSidebar.tsx | 7 +- .../src/components/voice/VoiceControlBar.tsx | 9 +- packages/web/src/hooks/useWebSocket.ts | 148 ++++++++++-------- packages/web/src/stores/voiceStore.ts | 6 +- 4 files changed, 97 insertions(+), 73 deletions(-) diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index c251199b..2400788b 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; -import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore'; +import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; import { useAuthStore } from '../../stores/authStore'; @@ -35,10 +35,11 @@ export function ChannelSidebar() { const toggleMic = useVoiceStore((s) => s.toggleMic); const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); + const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === user?.id)?.userId ?? user?.id); const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); - const isServerMuted = !!(user && spaceId && serverMutedUserIds.has(`${spaceId}:${user.id}`)); - const isServerDeafened = !!(user && spaceId && serverDeafenedUserIds.has(`${spaceId}:${user.id}`)); + const isServerMuted = !!(myOriginId && spaceId && serverMutedUserIds.has(`${spaceId}:${myOriginId}`)); + const isServerDeafened = !!(myOriginId && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myOriginId}`)); const navigate = useNavigate(); const location = useLocation(); diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index ff47080c..aee50b8b 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -4,7 +4,7 @@ import { useUIStore } from '../../stores/uiStore'; import { useAuthStore } from '../../stores/authStore'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { wsSend } from '../../hooks/useWebSocket'; -import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore'; +import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore'; import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare'; @@ -28,11 +28,12 @@ export function VoiceControlBar() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const myUser = useAuthStore((s) => s.user); const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); + const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; + const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === myUser?.id)?.userId ?? myUser?.id); const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); - const isServerMuted = !!(myUser && spaceId && serverMutedUserIds.has(`${spaceId}:${myUser.id}`)); - const isServerDeafened = !!(myUser && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myUser.id}`)); - const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; + const isServerMuted = !!(myOriginId && spaceId && serverMutedUserIds.has(`${spaceId}:${myOriginId}`)); + const isServerDeafened = !!(myOriginId && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myOriginId}`)); const [qualityOpen, setQualityOpen] = useState(false); const qualityBtnRef = useRef(null); diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 58a39fc0..887aa47b 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -319,27 +319,37 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'voice_server_muted': { const { setServerMutedUser } = useVoiceStore.getState(); setServerMutedUser(event.spaceId, event.userId, event.muted); - const myUserId = useAuthStore.getState().user?.id; - if (event.userId === myUserId) { - if (event.muted) { - // Force-mute the mic - const vs = useVoiceStore.getState(); - if (!vs.isMuted) { - useVoiceStore.setState({ isMuted: true }); - const fresh = useVoiceStore.getState(); - const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: true, isDeafened: fresh.isDeafened, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); - } - } else { - // Server unmuted — auto-restore mic unless still server-deafened - const vs = useVoiceStore.getState(); - if (!vs.serverDeafenedUserIds.has(`${event.spaceId}:${myUserId}`) && vs.isMuted) { - useVoiceStore.setState({ isMuted: false }); - const fresh = useVoiceStore.getState(); - const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: false, isDeafened: fresh.isDeafened, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); + + const checkMute = (myUserId: string | undefined) => { + if (event.userId === myUserId) { + if (event.muted) { + // Force-mute the mic + const vs = useVoiceStore.getState(); + if (!vs.isMuted) { + useVoiceStore.setState({ isMuted: true }); + const fresh = useVoiceStore.getState(); + const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; + wsSend({ type: 'voice_status', isMuted: true, isDeafened: fresh.isDeafened, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); + } + } else { + // Server unmuted — auto-restore mic unless still server-deafened + const vs = useVoiceStore.getState(); + if (!vs.serverDeafenedUserIds.has(`${event.spaceId}:${myUserId}`) && vs.isMuted) { + useVoiceStore.setState({ isMuted: false }); + const fresh = useVoiceStore.getState(); + const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; + wsSend({ type: 'voice_status', isMuted: false, isDeafened: fresh.isDeafened, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); + } } } + }; + + if (isHome) { + checkMute(useAuthStore.getState().user?.id); + } else { + import('../stores/spaceStore').then(({ getMyUserIdForOrigin }) => { + checkMute(getMyUserIdForOrigin(origin)); + }); } break; } @@ -347,53 +357,63 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'voice_server_deafened': { const { setServerDeafenedUser } = useVoiceStore.getState(); setServerDeafenedUser(event.spaceId, event.userId, event.deafened); - const myUid = useAuthStore.getState().user?.id; - if (event.userId === myUid) { - if (event.deafened) { - // Force-deafen (smart toggle sets both muted+deafened) - const vs = useVoiceStore.getState(); - if (!vs.isDeafened) { - useVoiceStore.setState({ isMuted: true, isDeafened: true }); - const fresh = useVoiceStore.getState(); - const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); - // Broadcast deafen to in-room participants via LiveKit data channel - import('./useLiveKit').then(({ getActiveRoom }) => { - const room = getActiveRoom(); - if (room) { - const encoder = new TextEncoder(); - room.localParticipant.publishData( - encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })), - { reliable: true } - ).catch(() => {}); - } - }); - } - } else { - // Server un-deafened — auto-restore - const vs = useVoiceStore.getState(); - if (vs.isDeafened) { - const stillServerMuted = vs.serverMutedUserIds.has(`${event.spaceId}:${myUid}`); - useVoiceStore.setState({ - isDeafened: false, - ...(stillServerMuted ? {} : { isMuted: false }), - }); - const fresh = useVoiceStore.getState(); - const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: fresh.isMuted, isDeafened: false, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); - // Broadcast undeafen via LiveKit data channel - import('./useLiveKit').then(({ getActiveRoom }) => { - const room = getActiveRoom(); - if (room) { - const encoder = new TextEncoder(); - room.localParticipant.publishData( - encoder.encode(JSON.stringify({ type: 'deafen', deafened: false })), - { reliable: true } - ).catch(() => {}); - } - }); + + const checkDeafen = (myUid: string | undefined) => { + if (event.userId === myUid) { + if (event.deafened) { + // Force-deafen (smart toggle sets both muted+deafened) + const vs = useVoiceStore.getState(); + if (!vs.isDeafened) { + useVoiceStore.setState({ isMuted: true, isDeafened: true }); + const fresh = useVoiceStore.getState(); + const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; + wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); + // Broadcast deafen to in-room participants via LiveKit data channel + import('./useLiveKit').then(({ getActiveRoom }) => { + const room = getActiveRoom(); + if (room) { + const encoder = new TextEncoder(); + room.localParticipant.publishData( + encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })), + { reliable: true } + ).catch(() => {}); + } + }); + } + } else { + // Server un-deafened — auto-restore + const vs = useVoiceStore.getState(); + if (vs.isDeafened) { + const stillServerMuted = vs.serverMutedUserIds.has(`${event.spaceId}:${myUid}`); + useVoiceStore.setState({ + isDeafened: false, + ...(stillServerMuted ? {} : { isMuted: false }), + }); + const fresh = useVoiceStore.getState(); + const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; + wsSend({ type: 'voice_status', isMuted: fresh.isMuted, isDeafened: false, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); + // Broadcast undeafen via LiveKit data channel + import('./useLiveKit').then(({ getActiveRoom }) => { + const room = getActiveRoom(); + if (room) { + const encoder = new TextEncoder(); + room.localParticipant.publishData( + encoder.encode(JSON.stringify({ type: 'deafen', deafened: false })), + { reliable: true } + ).catch(() => {}); + } + }); + } } } + }; + + if (isHome) { + checkDeafen(useAuthStore.getState().user?.id); + } else { + import('../stores/spaceStore').then(({ getMyUserIdForOrigin }) => { + checkDeafen(getMyUserIdForOrigin(origin)); + }); } break; } diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 297aa3a0..a9980a1a 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -241,7 +241,8 @@ export const useVoiceStore = create()( toggleMic: () => set((state) => { // Server-muted/deafened users cannot unmute themselves - const myId = useAuthStore.getState().user?.id; + const origin = state.currentVoiceChannelId ? getChannelOrigin(state.currentVoiceChannelId) : ''; + const myId = getMyUserIdForOrigin(origin); const spaceId = state.currentVoiceChannelId ? useSpaceStore.getState().channelToSpaceMap.get(state.currentVoiceChannelId) : null; if (myId && spaceId && state.isMuted && (state.serverMutedUserIds.has(`${spaceId}:${myId}`) || state.serverDeafenedUserIds.has(`${spaceId}:${myId}`))) { return {}; @@ -254,7 +255,8 @@ export const useVoiceStore = create()( }), toggleDeafen: () => set((state) => { // Server-deafened users cannot undeafen themselves - const myId = useAuthStore.getState().user?.id; + const origin = state.currentVoiceChannelId ? getChannelOrigin(state.currentVoiceChannelId) : ''; + const myId = getMyUserIdForOrigin(origin); const spaceId = state.currentVoiceChannelId ? useSpaceStore.getState().channelToSpaceMap.get(state.currentVoiceChannelId) : null; if (myId && spaceId && state.isDeafened && state.serverDeafenedUserIds.has(`${spaceId}:${myId}`)) { return {};