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:
@@ -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).
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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'}
|
||||
</button>
|
||||
)}
|
||||
{canDisconnectMembers && (
|
||||
<>
|
||||
<div className="h-px bg-white/[0.06] my-1 mx-1.5" />
|
||||
<button onClick={handleDisconnect} className={`${btnClass} text-red-400 hover:text-white`} style={btnStyle}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
||||
<path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 010-1.36C3.36 8.68 7.42 7 12 7s8.64 1.68 11.71 4.72c.18.18.29.44.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28a11.27 11.27 0 00-2.67-1.85.996.996 0 01-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" />
|
||||
</svg>
|
||||
Disconnect
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canMoveMembers && otherVoiceChannels.length > 0 && (
|
||||
<MoveToSubmenu channels={otherVoiceChannels} onMove={handleMove} btnClass={btnClass} btnStyle={btnStyle} />
|
||||
)}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user