Add access control rework
This commit is contained in:
@@ -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' }));
|
||||
|
||||
@@ -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, ' ');
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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 }>(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user