From e8fc40ab343dcbcfe1fa313cfeb876a38ea0256f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:13:20 +0100 Subject: [PATCH] feat: add voice disconnect permission and fix federation identity - Add DISCONNECT_MEMBERS permission (bit 27) to disconnect users from voice - Implement voice_disconnect WebSocket handler with permission checks - Add disconnect button to voice user context menu - Grant instance admins full permissions across all spaces - Fix voice_disconnected handler to use federation-aware identity resolution - Update CLAUDE.md with new event types and permission docs --- CLAUDE.md | 3 ++ packages/server/src/utils/permissions.ts | 4 ++ packages/server/src/ws/events.ts | 53 +++++++++++++++++++ packages/shared/src/permissions.ts | 1 + packages/shared/src/types.ts | 2 + .../modals/spaceSettingsPanels/RolesPanel.tsx | 1 + .../components/voice/VoiceUserContextMenu.tsx | 22 +++++++- packages/web/src/hooks/useWebSocket.ts | 8 +++ 8 files changed, 92 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 580afca9..1bdd8cf8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -531,6 +531,7 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t { type: 'voice_join', channelId } { type: 'voice_leave' } { type: 'voice_status', isMuted?, isDeafened?, isCameraOn?, isScreenSharing? } +{ type: 'voice_disconnect', userId } # DM Calls { type: 'dm_call_start', dmChannelId } @@ -576,6 +577,7 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t # Voice { type: 'voice_state_update', channelId, userId, action: 'join' | 'leave' } { type: 'voice_status_update', userId, isMuted, isDeafened, isCameraOn, isScreenSharing } +{ type: 'voice_disconnected', userId, channelId } # DM Calls { type: 'dm_call_incoming', dmChannelId, callerId, callerName } @@ -615,6 +617,7 @@ Bitwise permission engine defined in `packages/shared/src/permissions.ts`. Store | 24 | MOVE_MEMBERS | Move members between voice channels | | 25 | USE_VOICE_ACTIVITY | Use voice activity detection | | 26 | STREAM | Share screen in voice channels | +| 27 | DISCONNECT_MEMBERS | Disconnect members from voice channels | **Resolution order:** Owner → @everyone role → Assigned roles (OR'd) → ADMINISTRATOR shortcut → Channel overrides (@everyone → role overrides → member override). diff --git a/packages/server/src/utils/permissions.ts b/packages/server/src/utils/permissions.ts index 76e68071..39b4f24a 100644 --- a/packages/server/src/utils/permissions.ts +++ b/packages/server/src/utils/permissions.ts @@ -32,6 +32,10 @@ export function computePermissions(userId: string, spaceId: string, channelId?: if (!space) return 0n; if (space.ownerId === userId) return ALL_PERMISSIONS; + // 1b. Instance admin — full access across all spaces + const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + if (userRow?.isAdmin === 1) return ALL_PERMISSIONS; + // 2. Base permissions from @everyone role (id === spaceId) const everyoneRole = db.select().from(schema.roles) .where(and(eq(schema.roles.id, spaceId), eq(schema.roles.spaceId, spaceId))) diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 127b8017..2c4d783b 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -159,6 +159,9 @@ export function handleClientEvent( case 'voice_move': handleVoiceMove(event, userId); break; + case 'voice_disconnect': + handleVoiceDisconnect(event, userId); + break; default: connectionManager.sendToUser(userId, { type: 'error', @@ -1338,3 +1341,53 @@ function handleVoiceMove(event: Record, userId: string): void { }); } } + +function handleVoiceDisconnect(event: Record, userId: string): void { + const targetUserId = event.userId as string; + + if (!targetUserId || typeof targetUserId !== 'string') { + connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' }); + return; + } + + if (targetUserId === userId) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot disconnect yourself' }); + return; + } + + // Find the target user's current room + const currentRoom = connectionManager.getUserRoom(targetUserId); + if (!currentRoom || currentRoom.room.roomType !== 'space') { + connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' }); + return; + } + + const meta = currentRoom.room.metadata as SpaceRoomMeta; + if (!hasPermission(userId, meta.spaceId, PermissionBits.DISCONNECT_MEMBERS, currentRoom.roomId)) { + connectionManager.sendToUser(userId, { type: 'error', message: 'Missing DISCONNECT_MEMBERS permission' }); + return; + } + + const channelId = currentRoom.roomId; + + // Remove from voice room + connectionManager.leaveRoom(channelId, targetUserId); + + // Clear ephemeral voice status (mute/camera/etc) + connectionManager.clearVoiceUserStatus(targetUserId); + + // Broadcast leave to all space members + connectionManager.sendToSpace(meta.spaceId, { + type: 'voice_state_update', + channelId, + userId: targetUserId, + action: 'leave', + }); + + // Notify the disconnected user so they clean up client-side + connectionManager.sendToUser(targetUserId, { + type: 'voice_disconnected', + userId: targetUserId, + channelId, + }); +} diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index f444612b..9456b067 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -23,6 +23,7 @@ export const PermissionBits = { MOVE_MEMBERS: 1n << 24n, USE_VOICE_ACTIVITY: 1n << 25n, STREAM: 1n << 26n, + DISCONNECT_MEMBERS: 1n << 27n, } as const; export type PermissionBit = (typeof PermissionBits)[keyof typeof PermissionBits]; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8ed3a2fb..96465387 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -240,6 +240,7 @@ export type ClientEvent = | { type: 'voice_server_mute'; userId: string; muted: boolean } | { type: 'voice_server_deafen'; userId: string; deafened: boolean } | { type: 'voice_move'; userId: string; targetChannelId: string } + | { type: 'voice_disconnect'; userId: string } | { type: 'ping' }; // Server → Client Events @@ -282,6 +283,7 @@ export type ServerEvent = | { type: 'voice_server_muted'; userId: string; channelId: string; spaceId: string; muted: boolean } | { type: 'voice_server_deafened'; userId: string; channelId: string; spaceId: string; deafened: boolean } | { type: 'voice_moved'; userId: string; oldChannelId: string; newChannelId: string } + | { type: 'voice_disconnected'; userId: string; channelId: string } | { type: 'member_banned'; spaceId: string; reason: string | null } | { type: 'pong' } | { type: 'error'; message: string }; diff --git a/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx index bd1455bc..e72b517f 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx @@ -43,6 +43,7 @@ const PERMISSION_GROUPS: { name: string; perms: PermDef[] }[] = [ { bit: PermissionBits.MUTE_MEMBERS, label: 'Mute Members' }, { bit: PermissionBits.DEAFEN_MEMBERS, label: 'Deafen Members' }, { bit: PermissionBits.MOVE_MEMBERS, label: 'Move Members' }, + { bit: PermissionBits.DISCONNECT_MEMBERS, label: 'Disconnect Members' }, { bit: PermissionBits.USE_VOICE_ACTIVITY, label: 'Voice Activity' }, { bit: PermissionBits.STREAM, label: 'Stream' }, ], diff --git a/packages/web/src/components/voice/VoiceUserContextMenu.tsx b/packages/web/src/components/voice/VoiceUserContextMenu.tsx index d90080fb..5d762813 100644 --- a/packages/web/src/components/voice/VoiceUserContextMenu.tsx +++ b/packages/web/src/components/voice/VoiceUserContextMenu.tsx @@ -28,6 +28,7 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo const canMuteMembers = hasPermissionBit(myPerms, PermissionBits.MUTE_MEMBERS); const canDeafenMembers = hasPermissionBit(myPerms, PermissionBits.DEAFEN_MEMBERS); const canMoveMembers = hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS); + const canDisconnectMembers = hasPermissionBit(myPerms, PermissionBits.DISCONNECT_MEMBERS); const otherVoiceChannels = channels.filter( (c) => (c.type === 'voice' || c.type === 'video') && c.id !== channelId, @@ -39,7 +40,7 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo const isServerMuted = serverMutedUserIds.has(`${spaceId}:${targetUserId}`); const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${targetUserId}`); - if (!canMuteMembers && !canDeafenMembers && !canMoveMembers) return null; + if (!canMuteMembers && !canDeafenMembers && !canMoveMembers && !canDisconnectMembers) return null; const handleServerMute = () => { wsSend({ type: 'voice_server_mute', userId: targetUserId, muted: !isServerMuted }, voiceOrigin); @@ -56,6 +57,11 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo onAction(); }; + const handleDisconnect = () => { + wsSend({ type: 'voice_disconnect', userId: targetUserId }, voiceOrigin); + onAction(); + }; + const btnClass = 'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white'; const btnStyle = { width: 'calc(100% - 12px)' }; @@ -84,6 +90,17 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo {isServerDeafened ? 'Server Undeafen' : 'Server Deafen'} )} + {canDisconnectMembers && ( + <> +
+ + + )} {canMoveMembers && otherVoiceChannels.length > 0 && ( )} @@ -224,7 +241,8 @@ export function VoiceUserContextMenu({ targetUserId, channelId, position, onClos const canMuteMembers = hasPermissionBit(myPerms, PermissionBits.MUTE_MEMBERS); const canDeafenMembers = hasPermissionBit(myPerms, PermissionBits.DEAFEN_MEMBERS); const canMoveMembers = hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS); - const hasModPerms = canMuteMembers || canDeafenMembers || canMoveMembers; + const canDisconnectMembers = hasPermissionBit(myPerms, PermissionBits.DISCONNECT_MEMBERS); + const hasModPerms = canMuteMembers || canDeafenMembers || canMoveMembers || canDisconnectMembers; const participantVolumes = useVoiceStore((s) => s.participantVolumes); const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume); diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 8da872b5..3524c2a9 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -345,6 +345,14 @@ function handleEvent(origin: string, event: ServerEvent): void { break; } + case 'voice_disconnected': { + const myDisconnectId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin); + if (event.userId === myDisconnectId) { + useVoiceStore.getState().handleForceDisconnect(); + } + break; + } + case 'member_joined': if (!isHome) normalizeUserAssets(event.member.user, origin); addMember(event.member);