Add access control rework

This commit is contained in:
2026-04-02 03:18:37 +02:00
parent 314a26325f
commit 37cac95b38
111 changed files with 5355 additions and 1892 deletions

View File

@@ -44,6 +44,7 @@ import {
} from '../../shared-kernel';
import { hydrateMessages } from './messages.helpers';
import { canEditMessage } from '../../domains/chat/domain/message.rules';
import { resolveRoomPermission } from '../../domains/access-control';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
@Injectable()
@@ -244,16 +245,17 @@ export class MessagesEffects {
adminDeleteMessage$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.adminDeleteMessage),
withLatestFrom(this.store.select(selectCurrentUser)),
mergeMap(([{ messageId }, currentUser]) => {
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
mergeMap(([
{ messageId },
currentUser,
currentRoom
]) => {
if (!currentUser) {
return of(MessagesActions.deleteMessageFailure({ error: 'Not logged in' }));
}
const hasPermission =
currentUser.role === 'host' ||
currentUser.role === 'admin' ||
currentUser.role === 'moderator';
const hasPermission = !!currentRoom && resolveRoomPermission(currentRoom, currentUser, 'deleteMessages');
if (!hasPermission) {
return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' }));

View File

@@ -1,7 +1,4 @@
import {
Channel,
ChannelType
} from '../../shared-kernel';
import { Channel, ChannelType } from '../../shared-kernel';
export function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');

View File

@@ -16,6 +16,16 @@ function fallbackUsername(member: Partial<RoomMember>): string {
return base || member.oderId || member.id || 'user';
}
function normalizeRoleIds(roleIds: readonly string[] | undefined): string[] | undefined {
if (!Array.isArray(roleIds)) {
return undefined;
}
const normalized = Array.from(new Set(roleIds.filter((roleId): roleId is string => typeof roleId === 'string' && roleId.trim().length > 0).map((roleId) => roleId.trim())));
return normalized.length > 0 ? normalized : undefined;
}
function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
const key = getRoomMemberKey(member);
const lastSeenAt =
@@ -36,6 +46,7 @@ function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
displayName: fallbackDisplayName(member),
avatarUrl: member.avatarUrl || undefined,
role: member.role || 'member',
roleIds: normalizeRoleIds(member.roleIds),
joinedAt,
lastSeenAt
};
@@ -93,6 +104,9 @@ function mergeMembers(
? (normalizedIncoming.avatarUrl || normalizedExisting.avatarUrl)
: (normalizedExisting.avatarUrl || normalizedIncoming.avatarUrl),
role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming),
roleIds: preferIncoming
? (normalizedIncoming.roleIds || normalizedExisting.roleIds)
: (normalizedExisting.roleIds || normalizedIncoming.roleIds),
joinedAt: Math.min(normalizedExisting.joinedAt, normalizedIncoming.joinedAt),
lastSeenAt: Math.max(normalizedExisting.lastSeenAt, normalizedIncoming.lastSeenAt)
};

View File

@@ -58,6 +58,10 @@ export const RoomsActions = createActionGroup({
'Update Room Settings Failure': props<{ error: string }>(),
'Update Room Permissions': props<{ roomId: string; permissions: Partial<RoomPermissions> }>(),
'Update Room Access Control': props<{
roomId: string;
changes: Pick<Partial<Room>, 'roles' | 'roleAssignments' | 'channelPermissions' | 'slowModeInterval'>;
}>(),
'Update Server Icon': props<{ roomId: string; icon: string }>(),
'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),

View File

@@ -2,19 +2,13 @@
/* eslint-disable id-length */
/* eslint-disable @typescript-eslint/no-unused-vars,, complexity */
import { Injectable, inject } from '@angular/core';
import {
NavigationEnd,
Router
} from '@angular/router';
import { NavigationEnd, Router } from '@angular/router';
import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import {
Action,
Store
} from '@ngrx/store';
import { Action, Store } from '@ngrx/store';
import {
of,
from,
@@ -53,6 +47,12 @@ import {
type ServerSourceSelector,
ServerDirectoryFacade
} from '../../domains/server-directory';
import {
normalizeRoomAccessControl,
resolveLegacyRole,
resolveRoomPermission,
withLegacyRoomPermissions
} from '../../domains/access-control';
import {
ChatEvent,
Room,
@@ -392,6 +392,10 @@ export class RoomsEffects {
...room,
isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate,
channels: resolveRoomChannels(room.channels, serverInfo?.channels),
slowModeInterval: serverInfo?.slowModeInterval ?? room.slowModeInterval,
roles: serverInfo?.roles ?? room.roles,
roleAssignments: serverInfo?.roleAssignments ?? room.roleAssignments,
channelPermissions: serverInfo?.channelPermissions ?? room.channelPermissions,
sourceId: serverInfo?.sourceId ?? room.sourceId,
sourceName: serverInfo?.sourceName ?? room.sourceName,
sourceUrl: serverInfo?.sourceUrl ?? room.sourceUrl,
@@ -406,6 +410,10 @@ export class RoomsEffects {
sourceName: resolvedRoom.sourceName,
sourceUrl: resolvedRoom.sourceUrl,
channels: resolvedRoom.channels,
slowModeInterval: resolvedRoom.slowModeInterval,
roles: resolvedRoom.roles,
roleAssignments: resolvedRoom.roleAssignments,
channelPermissions: resolvedRoom.channelPermissions,
hasPassword: resolvedRoom.hasPassword,
isPrivate: resolvedRoom.isPrivate
});
@@ -426,6 +434,10 @@ export class RoomsEffects {
userCount: 1,
maxUsers: 50,
channels: resolveRoomChannels(undefined, serverInfo.channels),
slowModeInterval: serverInfo.slowModeInterval,
roles: serverInfo.roles,
roleAssignments: serverInfo.roleAssignments,
channelPermissions: serverInfo.channelPermissions,
sourceId: serverInfo.sourceId,
sourceName: serverInfo.sourceName,
sourceUrl: serverInfo.sourceUrl
@@ -451,6 +463,10 @@ export class RoomsEffects {
userCount: serverData.userCount,
maxUsers: serverData.maxUsers,
channels: resolveRoomChannels(undefined, serverData.channels),
slowModeInterval: serverData.slowModeInterval,
roles: serverData.roles,
roleAssignments: serverData.roleAssignments,
channelPermissions: serverData.channelPermissions,
sourceId: serverData.sourceId,
sourceName: serverData.sourceName,
sourceUrl: serverData.sourceUrl
@@ -498,105 +514,102 @@ export class RoomsEffects {
{ dispatch: false }
);
/** Remembers the viewed room whenever a room becomes active. */
persistLastViewedChatOnRoomActivation$ = createEffect(
() =>
this.actions$.pipe(
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([
{ room },
currentUser
]) => {
if (!currentUser) {
return;
}
const persisted = loadLastViewedChatFromStorage(currentUser.id);
const channelId = persisted?.roomId === room.id
? resolveTextChannelId(room.channels, persisted.channelId)
: resolveTextChannelId(room.channels);
saveLastViewedChatToStorage({
userId: currentUser.id,
roomId: room.id,
channelId
});
})
),
{ dispatch: false }
);
/** Remembers the currently selected text channel for the active room. */
persistLastViewedChatOnChannelSelection$ = createEffect(
() =>
this.actions$.pipe(
ofType(RoomsActions.selectChannel),
withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectCurrentUser)),
tap(([
{ channelId },
currentRoom,
currentUser
]) => {
if (!currentRoom || !currentUser) {
return;
}
const resolvedChannelId = resolveTextChannelId(currentRoom.channels, channelId);
if (!resolvedChannelId || resolvedChannelId !== channelId) {
return;
}
saveLastViewedChatToStorage({
userId: currentUser.id,
roomId: currentRoom.id,
channelId
});
})
),
{ dispatch: false }
);
/** Restores the last viewed text channel once the active room's channels are known. */
restoreLastViewedTextChannel$ = createEffect(() =>
/** Remembers the viewed room whenever a room becomes active. */
persistLastViewedChatOnRoomActivation$ = createEffect(
() =>
this.actions$.pipe(
ofType(
RoomsActions.createRoomSuccess,
RoomsActions.joinRoomSuccess,
RoomsActions.viewServerSuccess,
RoomsActions.updateRoom
),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectActiveChannelId)
),
mergeMap(([
, currentUser,
currentRoom,
activeChannelId
]) => {
if (!currentUser || !currentRoom) {
return EMPTY;
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([{ room }, currentUser]) => {
if (!currentUser) {
return;
}
const persisted = loadLastViewedChatFromStorage(currentUser.id);
const channelId = persisted?.roomId === room.id
? resolveTextChannelId(room.channels, persisted.channelId)
: resolveTextChannelId(room.channels);
if (!persisted || persisted.roomId !== currentRoom.id) {
return EMPTY;
}
const channelId = resolveTextChannelId(currentRoom.channels, persisted.channelId);
if (!channelId || channelId === activeChannelId) {
return EMPTY;
}
return of(RoomsActions.selectChannel({ channelId }));
saveLastViewedChatToStorage({
userId: currentUser.id,
roomId: room.id,
channelId
});
})
)
);
),
{ dispatch: false }
);
/** Remembers the currently selected text channel for the active room. */
persistLastViewedChatOnChannelSelection$ = createEffect(
() =>
this.actions$.pipe(
ofType(RoomsActions.selectChannel),
withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectCurrentUser)),
tap(([
{ channelId },
currentRoom,
currentUser
]) => {
if (!currentRoom || !currentUser) {
return;
}
const resolvedChannelId = resolveTextChannelId(currentRoom.channels, channelId);
if (!resolvedChannelId || resolvedChannelId !== channelId) {
return;
}
saveLastViewedChatToStorage({
userId: currentUser.id,
roomId: currentRoom.id,
channelId
});
})
),
{ dispatch: false }
);
/** Restores the last viewed text channel once the active room's channels are known. */
restoreLastViewedTextChannel$ = createEffect(() =>
this.actions$.pipe(
ofType(
RoomsActions.createRoomSuccess,
RoomsActions.joinRoomSuccess,
RoomsActions.viewServerSuccess,
RoomsActions.updateRoom
),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectActiveChannelId)
),
mergeMap(([
, currentUser,
currentRoom,
activeChannelId
]) => {
if (!currentUser || !currentRoom) {
return EMPTY;
}
const persisted = loadLastViewedChatFromStorage(currentUser.id);
if (!persisted || persisted.roomId !== currentRoom.id) {
return EMPTY;
}
const channelId = resolveTextChannelId(currentRoom.channels, persisted.channelId);
if (!channelId || channelId === activeChannelId) {
return EMPTY;
}
return of(RoomsActions.selectChannel({ channelId }));
})
)
);
refreshServerOwnedRoomMetadata$ = createEffect(() =>
this.actions$.pipe(
@@ -621,6 +634,10 @@ export class RoomsEffects {
isPrivate: serverData.isPrivate,
maxUsers: serverData.maxUsers,
channels: resolveRoomChannels(room.channels, serverData.channels),
slowModeInterval: serverData.slowModeInterval ?? room.slowModeInterval,
roles: serverData.roles ?? room.roles,
roleAssignments: serverData.roleAssignments ?? room.roleAssignments,
channelPermissions: serverData.channelPermissions ?? room.channelPermissions,
sourceId: serverData.sourceId ?? room.sourceId,
sourceName: serverData.sourceName ?? room.sourceName,
sourceUrl: serverData.sourceUrl ?? room.sourceUrl
@@ -856,7 +873,7 @@ export class RoomsEffects {
const currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom);
const isOwner = currentUserRole === 'host';
const canManageRoom = currentUserRole === 'host' || currentUserRole === 'admin';
const canManageRoom = isOwner || resolveRoomPermission(room, currentUser, 'manageServer');
if (!canManageRoom) {
return of(
@@ -949,7 +966,10 @@ export class RoomsEffects {
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
),
tap(([, currentUser, currentRoom]) => {
tap(([
, currentUser,
currentRoom
]) => {
if (!currentUser || !currentRoom) {
return;
}
@@ -1008,27 +1028,93 @@ export class RoomsEffects {
if (!room)
return EMPTY;
const isOwner =
room.hostId === currentUser.id ||
room.hostId === currentUser.oderId ||
(currentRoom?.id === room.id && currentUser.role === 'host');
const nextRoom = withLegacyRoomPermissions(room, permissions);
if (!isOwner)
return of(RoomsActions.updateRoomAccessControl({
roomId: room.id,
changes: {
roles: nextRoom.roles,
roleAssignments: nextRoom.roleAssignments,
channelPermissions: nextRoom.channelPermissions,
slowModeInterval: nextRoom.slowModeInterval
}
}));
})
)
);
updateRoomAccessControl$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.updateRoomAccessControl),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
{ roomId, changes },
currentUser,
currentRoom,
savedRooms
]) => {
if (!currentUser)
return EMPTY;
const updated: Partial<Room> = {
permissions: { ...(room.permissions || {}),
...permissions } as RoomPermissions
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
if (!room)
return EMPTY;
const requiresRoleManagement = !!changes.roles || !!changes.roleAssignments || !!changes.channelPermissions;
const requiresServerManagement = typeof changes.slowModeInterval === 'number';
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
const canManageRoles = isOwner || resolveRoomPermission(room, currentUser, 'manageRoles');
const canManageServer = isOwner || resolveRoomPermission(room, currentUser, 'manageServer');
if ((requiresRoleManagement && !canManageRoles) || (requiresServerManagement && !canManageServer)) {
return EMPTY;
}
const nextRoom = normalizeRoomAccessControl({
...room,
...changes
});
const nextChanges: Partial<Room> = {
roles: nextRoom.roles,
roleAssignments: nextRoom.roleAssignments,
channelPermissions: nextRoom.channelPermissions,
slowModeInterval: nextRoom.slowModeInterval,
permissions: nextRoom.permissions,
members: nextRoom.members
};
this.webrtc.broadcastMessage({
type: 'room-permissions-update',
roomId: room.id,
permissions: updated.permissions
permissions: nextRoom.permissions,
room: {
roles: nextRoom.roles,
roleAssignments: nextRoom.roleAssignments,
channelPermissions: nextRoom.channelPermissions,
slowModeInterval: nextRoom.slowModeInterval
}
});
this.serverDirectory.updateServer(room.id, {
currentOwnerId: currentUser.id,
roles: nextRoom.roles,
roleAssignments: nextRoom.roleAssignments,
channelPermissions: nextRoom.channelPermissions,
slowModeInterval: nextRoom.slowModeInterval
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
error: () => {}
});
return of(RoomsActions.updateRoom({ roomId: room.id,
changes: updated }));
changes: nextChanges }));
})
)
);
@@ -1047,12 +1133,8 @@ export class RoomsEffects {
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' }));
}
const role = currentUser.role;
const perms = currentRoom.permissions || {};
const isOwner = currentRoom.hostId === currentUser.id;
const canByRole =
(role === 'admin' && perms.adminsManageIcon) ||
(role === 'moderator' && perms.moderatorsManageIcon);
const canByRole = resolveRoomPermission(currentRoom, currentUser, 'manageIcon');
if (!isOwner && !canByRole) {
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
@@ -1472,6 +1554,7 @@ export class RoomsEffects {
serverDescription: room.description,
serverRoute: `/room/${room.id}`
});
this.voiceSessionService.setViewingVoiceServer(wasViewingVoiceServer);
this.webrtc.broadcastMessage({
type: 'voice-state',
@@ -1522,9 +1605,13 @@ export class RoomsEffects {
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
icon: typeof room.icon === 'string' ? room.icon : undefined,
iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined,
slowModeInterval: typeof room.slowModeInterval === 'number' ? room.slowModeInterval : undefined,
permissions: room.permissions ? { ...room.permissions } : undefined,
channels: Array.isArray(room.channels) ? room.channels : undefined,
members: Array.isArray(room.members) ? room.members : undefined,
roles: Array.isArray(room.roles) ? room.roles : undefined,
roleAssignments: Array.isArray(room.roleAssignments) ? room.roleAssignments : undefined,
channelPermissions: Array.isArray(room.channelPermissions) ? room.channelPermissions : undefined,
sourceId: typeof room.sourceId === 'string' ? room.sourceId : undefined,
sourceName: typeof room.sourceName === 'string' ? room.sourceName : undefined,
sourceUrl: typeof room.sourceUrl === 'string' ? room.sourceUrl : undefined
@@ -1665,16 +1752,23 @@ export class RoomsEffects {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
const incomingRoom = event.room as Partial<Room> | undefined;
if (!room || !permissions)
if (!room || (!permissions && !incomingRoom))
return EMPTY;
return of(
RoomsActions.updateRoom({
roomId: room.id,
changes: {
permissions: { ...(room.permissions || {}),
...permissions } as RoomPermissions
permissions: permissions
? { ...(room.permissions || {}),
...permissions } as RoomPermissions
: room.permissions,
roles: Array.isArray(incomingRoom?.roles) ? incomingRoom.roles : room.roles,
roleAssignments: Array.isArray(incomingRoom?.roleAssignments) ? incomingRoom.roleAssignments : room.roleAssignments,
channelPermissions: Array.isArray(incomingRoom?.channelPermissions) ? incomingRoom.channelPermissions : room.channelPermissions,
slowModeInterval: typeof incomingRoom?.slowModeInterval === 'number' ? incomingRoom.slowModeInterval : room.slowModeInterval
}
})
);
@@ -1764,11 +1858,8 @@ export class RoomsEffects {
if (!sender)
return EMPTY;
const perms = room.permissions || {};
const isOwner = room.hostId === sender.id;
const canByRole =
(sender.role === 'admin' && perms.adminsManageIcon) ||
(sender.role === 'moderator' && perms.moderatorsManageIcon);
const canByRole = resolveRoomPermission(room, sender, 'manageIcon');
if (!isOwner && !canByRole)
return EMPTY;
@@ -2004,15 +2095,7 @@ export class RoomsEffects {
}
private getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
if (room.hostId === currentUser.id || room.hostId === currentUser.oderId)
return 'host';
if (currentRoom?.id === room.id && currentUser.role)
return currentUser.role;
return findRoomMember(room.members ?? [], currentUser.id)?.role
|| findRoomMember(room.members ?? [], currentUser.oderId)?.role
|| null;
return resolveLegacyRole(currentRoom?.id === room.id ? currentRoom : room, currentUser);
}
private canManageChannelsInRoom(
@@ -2021,21 +2104,7 @@ export class RoomsEffects {
currentRoom: Room | null,
currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom)
): boolean {
if (currentUserRole === 'host') {
return true;
}
const permissions = room.permissions || {};
if (currentUserRole === 'admin' && permissions.adminsManageRooms) {
return true;
}
if (currentUserRole === 'moderator' && permissions.moderatorsManageRooms) {
return true;
}
return false;
return currentUserRole === 'host' || resolveRoomPermission(room, currentUser, 'manageChannels');
}
private getPersistedCurrentUserId(): string | null {

View File

@@ -1,8 +1,6 @@
import { createReducer, on } from '@ngrx/store';
import {
Room,
RoomSettings
} from '../../shared-kernel';
import { Room, RoomSettings } from '../../shared-kernel';
import { normalizeRoomAccessControl } from '../../domains/access-control';
import { type ServerInfo } from '../../domains/server-directory';
import { RoomsActions } from './rooms.actions';
import { defaultChannels } from './room-channels.defaults';
@@ -26,12 +24,12 @@ function deduplicateRooms(rooms: Room[]): Room[] {
/** Normalize room defaults and prune any stale persisted member entries. */
function enrichRoom(room: Room): Room {
return {
return normalizeRoomAccessControl({
...room,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
channels: normalizeRoomChannels(room.channels) || defaultChannels(),
members: pruneRoomMembers(room.members || [])
};
});
}
function resolveActiveTextChannelId(channels: Room['channels'], currentActiveChannelId: string): string {
@@ -422,8 +420,11 @@ export const roomsReducer = createReducer(
return state;
}
const updatedChannels = [...existing, { ...channel,
name: normalizedName }];
const updatedChannels = [
...existing,
{ ...channel,
name: normalizedName }
];
const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };

View File

@@ -35,6 +35,11 @@ import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../domains/server-directory';
import {
canManageMember,
resolveLegacyRole,
resolveRoomPermission
} from '../../domains/access-control';
import {
BanEntry,
ChatEvent,
@@ -157,7 +162,7 @@ export class UsersEffects {
if (!room)
return EMPTY;
const canKick = this.canKickInRoom(room, currentUser, currentRoom);
const canKick = this.canKickInRoom(room, currentUser, currentRoom, userId);
if (!canKick)
return EMPTY;
@@ -227,7 +232,7 @@ export class UsersEffects {
if (!room)
return EMPTY;
const canBan = this.canBanInRoom(room, currentUser, currentRoom);
const canBan = this.canBanInRoom(room, currentUser, currentRoom, userId);
if (!canBan)
return EMPTY;
@@ -487,31 +492,19 @@ export class UsersEffects {
private canModerateRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
return role === 'host' || role === 'admin';
return role === 'host' || resolveRoomPermission(room, currentUser, 'manageBans');
}
private canKickInRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
return role === 'host' || role === 'admin' || role === 'moderator';
private canKickInRoom(room: Room, currentUser: User, currentRoom: Room | null, targetUserId: string): boolean {
return canManageMember(room, currentUser, findRoomMember(room.members ?? [], targetUserId) ?? { id: targetUserId }, 'kickMembers');
}
private canBanInRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
return role === 'host' || role === 'admin';
private canBanInRoom(room: Room, currentUser: User, currentRoom: Room | null, targetUserId: string): boolean {
return canManageMember(room, currentUser, findRoomMember(room.members ?? [], targetUserId) ?? { id: targetUserId }, 'banMembers');
}
private getCurrentUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
return (
room.hostId === currentUser.id || room.hostId === currentUser.oderId
)
? 'host'
: (currentRoom?.id === room.id
? currentUser.role
: (findRoomMember(room.members ?? [], currentUser.id)?.role
|| findRoomMember(room.members ?? [], currentUser.oderId)?.role
|| null));
return resolveLegacyRole(currentRoom?.id === room.id ? currentRoom : room, currentUser);
}
private removeMemberFromRoom(room: Room, targetUserId: string): Partial<Room> {