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

@@ -22,11 +22,7 @@ import {
lucidePlus,
lucideVolumeX
} from '@ng-icons/lucide';
import {
selectOnlineUsers,
selectCurrentUser,
selectIsCurrentUserAdmin
} from '../../../store/users/users.selectors';
import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors';
import {
selectCurrentRoom,
selectActiveChannelId,
@@ -44,6 +40,12 @@ import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voic
import { VoicePlaybackService } from '../../../domains/voice-connection/application/voice-playback.service';
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules';
import {
canManageMember,
resolveRoomPermission,
setRoleAssignmentsForMember,
SYSTEM_ROLE_IDS
} from '../../../domains/access-control';
import {
ContextMenuComponent,
UserAvatarComponent,
@@ -108,7 +110,6 @@ export class RoomsSidePanelComponent {
onlineUsers = this.store.selectSignal(selectOnlineUsers);
currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels);
@@ -202,9 +203,9 @@ export class RoomsSidePanelComponent {
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
const current = this.currentUser();
return !!current && (
(typeof entity.id === 'string' && entity.id === current.id)
|| (typeof entity.oderId === 'string' && entity.oderId === current.oderId)
return (
!!current &&
((typeof entity.id === 'string' && entity.id === current.id) || (typeof entity.oderId === 'string' && entity.oderId === current.oderId))
);
}
@@ -215,18 +216,7 @@ export class RoomsSidePanelComponent {
if (!room || !user)
return false;
if (room.hostId === user.id)
return true;
const perms = room.permissions || {};
if (user.role === 'admin' && perms.adminsManageRooms)
return true;
if (user.role === 'moderator' && perms.moderatorsManageRooms)
return true;
return false;
return resolveRoomPermission(room, user, 'manageChannels');
}
selectTextChannel(channelId: string) {
@@ -317,11 +307,7 @@ export class RoomsSidePanelComponent {
return;
}
this.notifications.setChannelMuted(
roomId,
channel.id,
!this.notifications.isChannelMuted(roomId, channel.id)
);
this.notifications.setChannelMuted(roomId, channel.id, !this.notifications.isChannelMuted(roomId, channel.id));
}
isContextChannelMuted(): boolean {
@@ -410,9 +396,7 @@ export class RoomsSidePanelComponent {
}
const channels = this.currentRoom()?.channels ?? [];
const channelType = excludeChannelId
? channels.find((channel) => channel.id === excludeChannelId)?.type
: this.createChannelType();
const channelType = excludeChannelId ? channels.find((channel) => channel.id === excludeChannelId)?.type : this.createChannelType();
if (!channelType) {
return null;
@@ -428,7 +412,7 @@ export class RoomsSidePanelComponent {
openUserContextMenu(evt: MouseEvent, user: User) {
evt.preventDefault();
if (!this.isAdmin())
if (!this.canManageContextUser(user))
return;
this.contextMenuUser.set(user);
@@ -457,19 +441,22 @@ export class RoomsSidePanelComponent {
changeUserRole(role: 'admin' | 'moderator' | 'member') {
const user = this.contextMenuUser();
const roomId = this.currentRoom()?.id;
const room = this.currentRoom();
this.closeUserMenu();
if (user) {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
this.realtime.broadcastMessage({
type: 'role-change',
roomId,
targetUserId: user.id,
role
});
}
if (!user || !room)
return;
const roleIds = role === 'admin' ? [SYSTEM_ROLE_IDS.admin] : role === 'moderator' ? [SYSTEM_ROLE_IDS.moderator] : [];
const roleAssignments = setRoleAssignmentsForMember(room.roleAssignments, user, roleIds);
this.store.dispatch(
RoomsActions.updateRoomAccessControl({
roomId: room.id,
changes: { roleAssignments }
})
);
}
kickUserAction() {
@@ -482,52 +469,69 @@ export class RoomsSidePanelComponent {
}
}
private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
if (!room || !current?.voiceState?.isConnected || current.voiceState.roomId !== roomId || current.voiceState.serverId !== room.id) {
return false;
}
this.voiceWorkspace.open(null, { connectRemoteShares: true });
return true;
}
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
}
private prepareCrossServerVoiceJoin(room: Room, current: User | null): boolean {
if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
return true;
}
if (this.voiceConnection.isVoiceConnected()) {
return false;
}
if (current.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
})
);
}
return true;
}
private enableVoiceForJoin(room: Room, current: User | null, roomId: string): Promise<void> {
const isSwitchingChannels = !!current?.voiceState?.isConnected && current.voiceState.serverId === room.id && current.voiceState.roomId !== roomId;
return isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice().then(() => undefined);
}
joinVoice(roomId: string) {
const room = this.currentRoom();
const current = this.currentUser();
if (
room
&& current?.voiceState?.isConnected
&& current.voiceState.roomId === roomId
&& current.voiceState.serverId === room.id
) {
this.voiceWorkspace.open(null, { connectRemoteShares: true });
if (this.openExistingVoiceWorkspace(room, current ?? null, roomId)) {
return;
}
if (room && room.permissions && room.permissions.allowVoice === false) {
if (!room || !this.canJoinRequestedVoiceRoom(room, current ?? null, roomId)) {
return;
}
if (!room)
if (!this.prepareCrossServerVoiceJoin(room, current ?? null)) {
return;
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
if (!this.voiceConnection.isVoiceConnected()) {
if (current.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
})
);
}
} else {
return;
}
}
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice();
enableVoicePromise
this.enableVoiceForJoin(room, current ?? null, roomId)
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
.catch(() => undefined);
}
@@ -668,9 +672,7 @@ export class RoomsSidePanelComponent {
}
viewStream(userId: string) {
const focusTarget = this.isUserSharing(userId)
? `screen:${userId}`
: `camera:${userId}`;
const focusTarget = this.isUserSharing(userId) ? `screen:${userId}` : `camera:${userId}`;
this.voiceWorkspace.focusStream(focusTarget, { connectRemoteShares: true });
}
@@ -768,10 +770,12 @@ export class RoomsSidePanelComponent {
serverId: room.id
};
this.store.dispatch(UsersActions.updateVoiceState({
userId: targetUser.id,
voiceState: movedVoiceState
}));
this.store.dispatch(
UsersActions.updateVoiceState({
userId: targetUser.id,
voiceState: movedVoiceState
})
);
this.realtime.broadcastMessage({
type: 'voice-channel-move',
@@ -809,8 +813,7 @@ export class RoomsSidePanelComponent {
return false;
}
return this.getPeerKeysForUser(user, userId)
.some((peerKey) => this.hasActiveVideoStream(this.voiceConnection.getRemoteCameraStream(peerKey)));
return this.getPeerKeysForUser(user, userId).some((peerKey) => this.hasActiveVideoStream(this.voiceConnection.getRemoteCameraStream(peerKey)));
}
isUserSharing(userId: string): boolean {
@@ -834,9 +837,10 @@ export class RoomsSidePanelComponent {
return false;
}
const stream = this.getPeerKeysForUser(user, userId)
.map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey))
.find((candidate) => this.hasActiveVideoStream(candidate)) || null;
const stream =
this.getPeerKeysForUser(user, userId)
.map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey))
.find((candidate) => this.hasActiveVideoStream(candidate)) || null;
return this.hasActiveVideoStream(stream);
}
@@ -856,16 +860,10 @@ export class RoomsSidePanelComponent {
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
);
if (
me?.voiceState?.isConnected &&
me.voiceState?.roomId === roomId &&
me.voiceState?.serverId === room?.id
) {
if (me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id) {
const meId = me.id;
const meOderId = me.oderId;
const alreadyIncluded = remoteUsers.some(
(user) => user.id === meId || user.oderId === meOderId
);
const alreadyIncluded = remoteUsers.some((user) => user.id === meId || user.oderId === meOderId);
if (!alreadyIncluded) {
return [me, ...remoteUsers];
@@ -884,8 +882,42 @@ export class RoomsSidePanelComponent {
voiceEnabled(): boolean {
const room = this.currentRoom();
const user = this.currentUser();
return room?.permissions?.allowVoice !== false;
return !!room && !!user && resolveRoomPermission(room, user, 'joinVoice');
}
canManageContextUser(user: User | null): boolean {
const room = this.currentRoom();
const currentUser = this.currentUser();
if (!room || !currentUser || !user) {
return false;
}
return this.canChangeUserRole(user) || this.canKickUser(user);
}
canChangeUserRole(user: User | null): boolean {
const room = this.currentRoom();
const currentUser = this.currentUser();
if (!room || !currentUser || !user) {
return false;
}
return canManageMember(room, currentUser, user, 'manageRoles');
}
canKickUser(user: User | null): boolean {
const room = this.currentRoom();
const currentUser = this.currentUser();
if (!room || !currentUser || !user) {
return false;
}
return canManageMember(room, currentUser, user, 'kickMembers');
}
getPeerLatency(user: User): number | null {
@@ -934,9 +966,11 @@ export class RoomsSidePanelComponent {
return true;
}
return !!user?.voiceState?.isConnected
&& user.voiceState.roomId === currentVoiceState.roomId
&& user.voiceState.serverId === currentVoiceState.serverId;
return (
!!user?.voiceState?.isConnected &&
user.voiceState.roomId === currentVoiceState.roomId &&
user.voiceState.serverId === currentVoiceState.serverId
);
}
private getPeerKeysForUser(user: User | null, userId: string): string[] {
@@ -944,9 +978,7 @@ export class RoomsSidePanelComponent {
user?.oderId,
user?.id,
userId
].filter(
(candidate): candidate is string => !!candidate
);
].filter((candidate): candidate is string => !!candidate);
}
private hasActiveVideoStream(stream: MediaStream | null): boolean {