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
This commit is contained in:
Jannis Braun
2026-03-10 02:13:20 +01:00
parent 3cef9cef32
commit e8fc40ab34
8 changed files with 92 additions and 2 deletions
+4
View File
@@ -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)))
+53
View File
@@ -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<string, unknown>, userId: string): void {
});
}
}
function handleVoiceDisconnect(event: Record<string, unknown>, 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,
});
}