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

@@ -9,10 +9,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePower } from '@ng-icons/lucide';
import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron/electron-api.models';
import {
loadGeneralSettingsFromStorage,
saveGeneralSettingsToStorage
} from '../../../../infrastructure/persistence';
import { loadGeneralSettingsFromStorage, saveGeneralSettingsToStorage } from '../../../../infrastructure/persistence';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PlatformService } from '../../../../core/platform';

View File

@@ -1,69 +1,80 @@
@if (server()) {
<div class="space-y-3 max-w-xl">
<div class="space-y-3 max-w-3xl">
@if (members().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No other members found for this server</p>
} @else {
@for (member of members(); track member.oderId || member.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<app-user-avatar
[name]="member.displayName || '?'"
size="sm"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-foreground truncate">
{{ member.displayName }}
</p>
@if (member.isOnline) {
<span class="text-[10px] bg-emerald-500/20 text-emerald-400 px-1 py-0.5 rounded">Online</span>
}
@if (member.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
} @else if (member.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
} @else if (member.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
}
<div class="space-y-3 rounded-lg bg-secondary/50 p-3">
<div class="flex items-center gap-3">
<app-user-avatar
[name]="member.displayName || '?'"
size="sm"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<p class="truncate text-sm font-medium text-foreground">
{{ member.displayName }}
</p>
@if (member.isOnline) {
<span class="rounded bg-emerald-500/20 px-1 py-0.5 text-[10px] text-emerald-400">Online</span>
}
<span class="rounded bg-primary/10 px-1 py-0.5 text-[10px] text-primary">{{ member.displayRoleName }}</span>
</div>
<p class="text-xs text-muted-foreground">{{ member.username }}</p>
</div>
</div>
@if (member.role !== 'host' && isAdmin()) {
<div class="flex items-center gap-1">
@if (canChangeRoles()) {
<select
[ngModel]="member.role"
(ngModelChange)="changeRole(member, $event)"
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="member">Member</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
}
@if (canKickMembers()) {
@if (canKickMembers(member)) {
<button
(click)="kickMember(member)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
class="rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
title="Kick"
>
<ng-icon
name="lucideUserX"
class="w-4 h-4"
class="h-4 w-4"
/>
</button>
}
@if (canBanMembers()) {
@if (canBanMembers(member)) {
<button
(click)="banMember(member)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
class="rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
title="Ban"
>
<ng-icon
name="lucideBan"
class="w-4 h-4"
class="h-4 w-4"
/>
</button>
}
</div>
</div>
@if (assignableRoles().length > 0 && canChangeRoles(member)) {
<div class="space-y-2 border-t border-border/50 pt-3">
<p class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Assigned Roles</p>
<div class="flex flex-wrap gap-2">
@for (role of assignableRoles(); track role.id) {
<label class="flex items-center gap-2 rounded-full border border-border bg-background/70 px-3 py-1 text-xs text-foreground">
<input
type="checkbox"
[checked]="member.assignedRoleIds.includes(role.id)"
(change)="toggleRole(member, role.id, $event)"
class="h-3.5 w-3.5 accent-primary"
/>
<span
class="inline-block h-2.5 w-2.5 rounded-full"
[style.background]="role.color || '#94a3b8'"
></span>
<span>{{ role.name }}</span>
</label>
}
</div>
</div>
} @else if (assignableRoles().length > 0) {
<p class="border-t border-border/50 pt-3 text-xs text-muted-foreground">
You can view this member's roles, but you do not have permission to change them.
</p>
}
</div>
}

View File

@@ -14,16 +14,25 @@ import { lucideUserX, lucideBan } from '@ng-icons/lucide';
import {
Room,
RoomMember,
UserRole
RoomRole
} from '../../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser, selectUsersEntities } from '../../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { UserAvatarComponent } from '../../../../shared';
import {
canManageMember,
findAssignableRoles,
getDisplayRoleName,
getRoleIdsForMember,
normalizeRoomAccessControl,
setRoleAssignmentsForMember
} from '../../../../domains/access-control';
interface ServerMemberView extends RoomMember {
assignedRoleIds: string[];
displayRoleName: string;
isOnline: boolean;
}
@@ -46,20 +55,25 @@ interface ServerMemberView extends RoomMember {
})
export class MembersSettingsComponent {
private store = inject(Store);
private webrtcService = inject(RealtimeSessionFacade);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
accessRole = input<UserRole | null>(null);
accessRole = input<string | null>(null);
currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom);
usersEntities = this.store.selectSignal(selectUsersEntities);
normalizedServer = computed(() => {
const room = this.server();
return room ? normalizeRoomAccessControl(room) : null;
});
assignableRoles = computed<RoomRole[]>(() => findAssignableRoles(this.normalizedServer()?.roles ?? []));
members = computed<ServerMemberView[]>(() => {
const room = this.server();
const room = this.normalizedServer();
const me = this.currentUser();
const currentRoom = this.currentRoom();
const usersEntities = this.usersEntities();
@@ -78,6 +92,8 @@ export class MembersSettingsComponent {
return {
...member,
assignedRoleIds: getRoleIdsForMember(room, member),
displayRoleName: getDisplayRoleName(room, member),
avatarUrl: liveUser?.avatarUrl || member.avatarUrl,
displayName: liveUser?.displayName || member.displayName,
isOnline: !!liveUser && (liveUser.isOnline === true || liveUser.status !== 'offline')
@@ -85,55 +101,47 @@ export class MembersSettingsComponent {
});
});
canChangeRoles(): boolean {
const role = this.accessRole();
canChangeRoles(member: ServerMemberView): boolean {
const room = this.normalizedServer();
const currentUser = this.currentUser();
return role === 'host' || role === 'admin';
return !!room && !!currentUser && canManageMember(room, currentUser, member, 'manageRoles');
}
canKickMembers(): boolean {
const role = this.accessRole();
canKickMembers(member: ServerMemberView): boolean {
const room = this.normalizedServer();
const currentUser = this.currentUser();
return role === 'host' || role === 'admin' || role === 'moderator';
return !!room && !!currentUser && canManageMember(room, currentUser, member, 'kickMembers');
}
canBanMembers(): boolean {
const role = this.accessRole();
canBanMembers(member: ServerMemberView): boolean {
const room = this.normalizedServer();
const currentUser = this.currentUser();
return role === 'host' || role === 'admin';
return !!room && !!currentUser && canManageMember(room, currentUser, member, 'banMembers');
}
changeRole(member: ServerMemberView, role: 'admin' | 'moderator' | 'member'): void {
const room = this.server();
toggleRole(member: ServerMemberView, roleId: string, event: Event): void {
const room = this.normalizedServer();
if (!room)
return;
const members = (room.members ?? []).map((existingMember) =>
existingMember.id === member.id || existingMember.oderId === member.oderId
? { ...existingMember,
role }
: existingMember
);
const checkbox = event.target as HTMLInputElement;
const nextRoleIds = checkbox.checked
? [...member.assignedRoleIds, roleId]
: member.assignedRoleIds.filter((candidateRoleId) => candidateRoleId !== roleId);
const roleAssignments = setRoleAssignmentsForMember(room.roleAssignments, member, nextRoleIds);
this.store.dispatch(RoomsActions.updateRoom({ roomId: room.id,
changes: { members } }));
if (this.currentRoom()?.id === room.id) {
this.store.dispatch(UsersActions.updateUserRole({ userId: member.id,
role }));
}
this.webrtcService.broadcastMessage({
type: 'role-change',
this.store.dispatch(RoomsActions.updateRoomAccessControl({
roomId: room.id,
targetUserId: member.id,
role
});
changes: { roleAssignments }
}));
}
kickMember(member: ServerMemberView): void {
const room = this.server();
const room = this.normalizedServer();
if (!room)
return;
@@ -143,7 +151,7 @@ export class MembersSettingsComponent {
}
banMember(member: ServerMemberView): void {
const room = this.server();
const room = this.normalizedServer();
if (!room)
return;

View File

@@ -1,129 +1,275 @@
@if (server()) {
<div class="space-y-4 max-w-xl">
@if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-1">You are viewing this server's permissions. Only the server owner can make changes.</p>
}
<div class="space-y-2.5">
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow Voice Chat</p>
<p class="text-xs text-muted-foreground">Users can join voice channels</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowVoice"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow Screen Share</p>
<p class="text-xs text-muted-foreground">Users can share their screen</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowScreenShare"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow File Uploads</p>
<p class="text-xs text-muted-foreground">Users can upload files</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowFileUploads"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Slow Mode</p>
<p class="text-xs text-muted-foreground">Limit message frequency</p>
</div>
<select
[(ngModel)]="slowModeInterval"
[disabled]="!isAdmin()"
class="px-3 py-1 bg-secondary rounded border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">Off</option>
<option value="5">5 seconds</option>
<option value="10">10 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
</select>
</div>
<!-- Management permissions -->
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
<p class="text-xs text-muted-foreground">Allow admins to create/modify rooms</p>
</div>
<input
type="checkbox"
[(ngModel)]="adminsManageRooms"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
<p class="text-xs text-muted-foreground">Allow moderators to create/modify rooms</p>
</div>
<input
type="checkbox"
[(ngModel)]="moderatorsManageRooms"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
</div>
<input
type="checkbox"
[(ngModel)]="adminsManageIcon"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
</div>
<input
type="checkbox"
[(ngModel)]="moderatorsManageIcon"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
@if (normalizedServer(); as room) {
<div class="max-w-5xl space-y-4">
<div class="rounded-lg border border-border/60 bg-background/60 p-4">
<p class="text-sm text-foreground">
Roles now define who can moderate, manage channels, upload files, and join voice. Channel overrides are optional and apply on top of the base
role permissions.
</p>
@if (!canManageRoles()) {
<p class="mt-2 text-xs text-muted-foreground">You can inspect this server's access model, but only members with Manage Roles can edit it.</p>
}
</div>
@if (isAdmin()) {
<button
(click)="savePermissions()"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2 text-sm"
[class.bg-green-600]="saveSuccess() === 'permissions'"
[class.hover:bg-green-600]="saveSuccess() === 'permissions'"
>
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
{{ saveSuccess() === 'permissions' ? 'Saved!' : 'Save Permissions' }}
</button>
}
<div class="grid gap-4 xl:grid-cols-[16rem,minmax(0,1fr)]">
<div class="space-y-3 rounded-lg bg-secondary/50 p-3">
<div class="flex items-center justify-between gap-2">
<div>
<p class="text-sm font-medium text-foreground">Roles</p>
<p class="text-xs text-muted-foreground">Higher roles appear first.</p>
</div>
@if (canManageRoles()) {
<button
type="button"
(click)="createRole()"
class="inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground transition-colors hover:bg-background/80"
>
<ng-icon
name="lucidePlus"
class="h-3.5 w-3.5"
/>
<span>Role</span>
</button>
}
</div>
<div class="space-y-2">
@for (role of roles(); track role.id) {
<button
type="button"
(click)="selectRole(role.id)"
class="flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors"
[class.border-primary/60]="selectedRoleKey === role.id"
[class.bg-background]="selectedRoleKey === role.id"
[class.border-border/60]="selectedRoleKey !== role.id"
[class.bg-background/60]="selectedRoleKey !== role.id"
>
<span
class="h-2.5 w-2.5 rounded-full"
[style.background]="role.color || '#94a3b8'"
></span>
<span class="min-w-0 flex-1 truncate text-sm text-foreground">{{ role.name }}</span>
@if (role.isSystem) {
<span class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] uppercase tracking-[0.16em] text-primary">System</span>
}
</button>
}
</div>
</div>
<div class="space-y-4">
<div class="rounded-lg bg-secondary/50 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Slow Mode</p>
<p class="text-xs text-muted-foreground">Sets the minimum delay between messages for everyone in the server.</p>
</div>
<select
[ngModel]="slowModeValue(room.slowModeInterval)"
(ngModelChange)="updateSlowMode($event)"
[disabled]="!canManageServer()"
class="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">Off</option>
<option value="5">5 seconds</option>
<option value="10">10 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
<option value="120">2 minutes</option>
</select>
</div>
</div>
@if (selectedRole(); as role) {
<div class="space-y-4 rounded-lg bg-secondary/50 p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<span
class="h-3 w-3 rounded-full"
[style.background]="role.color || '#94a3b8'"
></span>
<p class="text-sm font-medium text-foreground">{{ role.name }}</p>
</div>
<p class="mt-1 text-xs text-muted-foreground">
Edit the role metadata here, then tune its global permissions and per-channel overrides below.
</p>
</div>
@if (role.isSystem) {
<span class="rounded bg-primary/10 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-primary">Protected role</span>
}
</div>
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr),8rem]">
<label class="space-y-1">
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Role Name</span>
<input
type="text"
[ngModel]="roleName"
(ngModelChange)="roleName = $event"
[disabled]="!canEditSelectedRoleMetadata()"
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
<label class="space-y-1">
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Color</span>
<input
type="color"
[ngModel]="roleColor"
(ngModelChange)="roleColor = $event"
[disabled]="!canEditSelectedRoleMetadata()"
class="h-10 w-full rounded-md border border-border bg-background px-1"
/>
</label>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
(click)="saveRoleDetails()"
[disabled]="!canEditSelectedRoleMetadata()"
class="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
<ng-icon
name="lucideCheck"
class="h-4 w-4"
/>
<span>Save Role</span>
</button>
<button
type="button"
(click)="moveSelectedRole('up')"
[disabled]="!canMoveSelectedRoleUp()"
class="inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-background/80 disabled:cursor-not-allowed disabled:opacity-50"
>
<ng-icon
name="lucideArrowUp"
class="h-4 w-4"
/>
<span>Move Up</span>
</button>
<button
type="button"
(click)="moveSelectedRole('down')"
[disabled]="!canMoveSelectedRoleDown()"
class="inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground transition-colors hover:bg-background/80 disabled:cursor-not-allowed disabled:opacity-50"
>
<ng-icon
name="lucideArrowDown"
class="h-4 w-4"
/>
<span>Move Down</span>
</button>
@if (!role.isSystem) {
<button
type="button"
(click)="deleteSelectedRole()"
[disabled]="!canEditSelectedRoleMetadata()"
class="inline-flex items-center gap-1 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50"
>
<ng-icon
name="lucideTrash2"
class="h-4 w-4"
/>
<span>Delete</span>
</button>
}
</div>
@if (role.isSystem) {
<p class="text-xs text-muted-foreground">
System roles can still have their permissions tuned, but their name, color, and membership in the base hierarchy stay fixed.
</p>
}
</div>
<div class="space-y-3 rounded-lg bg-secondary/50 p-4">
<div>
<p class="text-sm font-medium text-foreground">Base Permissions</p>
<p class="text-xs text-muted-foreground">These defaults apply everywhere unless a channel override changes them.</p>
</div>
<div class="space-y-2">
@for (permission of permissionDefinitions; track permission.key) {
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-background/60 p-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">{{ permission.label }}</p>
<p class="text-xs text-muted-foreground">{{ permission.description }}</p>
</div>
<select
[ngModel]="permissionState(permission.key)"
(ngModelChange)="setSelectedRolePermission(permission.key, coercePermissionState($event))"
[disabled]="!canEditSelectedRole()"
class="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (state of permissionStates; track state) {
<option [value]="state">
{{ state === 'inherit' ? 'Inherit' : state === 'allow' ? 'Allow' : 'Deny' }}
</option>
}
</select>
</div>
}
</div>
</div>
<div class="space-y-3 rounded-lg bg-secondary/50 p-4">
<div>
<p class="text-sm font-medium text-foreground">Channel Overrides</p>
<p class="text-xs text-muted-foreground">
Override the selected role inside a specific channel without changing the server-wide default.
</p>
</div>
@if (channels().length === 0) {
<p class="text-sm text-muted-foreground">This server has no channels yet.</p>
} @else {
<div class="flex items-center gap-3">
<label class="min-w-0 flex-1 space-y-1">
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Channel</span>
<select
[ngModel]="selectedChannelKey"
(ngModelChange)="selectChannel($event)"
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (channel of channels(); track channel.id) {
<option [value]="channel.id">{{ channel.name }} ({{ channel.type | titlecase }})</option>
}
</select>
</label>
</div>
<div class="space-y-2">
@for (permission of permissionDefinitions; track permission.key) {
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-background/60 p-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">{{ permission.label }}</p>
<p class="text-xs text-muted-foreground">{{ permission.description }}</p>
</div>
<select
[ngModel]="channelOverrideState(permission.key)"
(ngModelChange)="setChannelOverride(permission.key, coercePermissionState($event))"
[disabled]="!canEditSelectedRole()"
class="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (state of permissionStates; track state) {
<option [value]="state">
{{ state === 'inherit' ? 'Inherit' : state === 'allow' ? 'Allow' : 'Deny' }}
</option>
}
</select>
</div>
}
</div>
}
</div>
}
</div>
</div>
</div>
} @else {
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
<div class="flex h-40 items-center justify-center text-sm text-muted-foreground">Select a server from the sidebar to manage</div>
}

View File

@@ -1,18 +1,71 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
inject,
input,
signal
input
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import { lucideCheck } from '@ng-icons/lucide';
import {
lucideArrowDown,
lucideArrowUp,
lucideCheck,
lucidePlus,
lucideTrash2
} from '@ng-icons/lucide';
import { Room } from '../../../../shared-kernel';
import {
ChannelPermissionOverride,
PermissionState,
Room,
RoomPermissionKey,
RoomRole
} from '../../../../shared-kernel';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import {
canManageRole,
createCustomRoomRole,
normalizeRoomAccessControl,
removeRole,
reorderRoles,
resolveRoomPermission,
ROOM_PERMISSION_DEFINITIONS,
sortRolesForDisplay,
withUpdatedRole
} from '../../../../domains/access-control';
function upsertRoleChannelOverride(
overrides: readonly ChannelPermissionOverride[] | undefined,
channelId: string,
roleId: string,
permission: RoomPermissionKey,
value: PermissionState
): ChannelPermissionOverride[] {
const filteredOverrides = (overrides ?? []).filter(
(override) =>
!(override.channelId === channelId && override.targetType === 'role' && override.targetId === roleId && override.permission === permission)
);
if (value === 'inherit') {
return filteredOverrides;
}
return [
...filteredOverrides,
{
channelId,
targetType: 'role',
targetId: roleId,
permission,
value
}
];
}
@Component({
selector: 'app-permissions-settings',
@@ -24,7 +77,11 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
],
viewProviders: [
provideIcons({
lucideCheck
lucideArrowDown,
lucideArrowUp,
lucideCheck,
lucidePlus,
lucideTrash2
})
],
templateUrl: './permissions-settings.component.html'
@@ -32,68 +89,305 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
export class PermissionsSettingsComponent {
private store = inject(Store);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
allowVoice = true;
allowScreenShare = true;
allowFileUploads = true;
slowModeInterval = '0';
adminsManageRooms = false;
moderatorsManageRooms = false;
adminsManageIcon = false;
moderatorsManageIcon = false;
saveSuccess = signal<string | null>(null);
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
/** Load permissions from the server input. Called by parent via effect or on init. */
loadPermissions(room: Room): void {
const perms = room.permissions || {};
this.allowVoice = perms.allowVoice !== false;
this.allowScreenShare = perms.allowScreenShare !== false;
this.allowFileUploads = perms.allowFileUploads !== false;
this.slowModeInterval = String(perms.slowModeInterval ?? 0);
this.adminsManageRooms = !!perms.adminsManageRooms;
this.moderatorsManageRooms = !!perms.moderatorsManageRooms;
this.adminsManageIcon = !!perms.adminsManageIcon;
this.moderatorsManageIcon = !!perms.moderatorsManageIcon;
}
savePermissions(): void {
currentUser = this.store.selectSignal(selectCurrentUser);
permissionDefinitions = ROOM_PERMISSION_DEFINITIONS;
permissionStates: PermissionState[] = [
'inherit',
'allow',
'deny'
];
normalizedServer = computed(() => {
const room = this.server();
if (!room)
return room ? normalizeRoomAccessControl(room) : null;
});
roles = computed<RoomRole[]>(() => sortRolesForDisplay(this.normalizedServer()?.roles ?? []));
channels = computed(() => this.normalizedServer()?.channels ?? []);
canManageRoles = computed(() => {
const room = this.normalizedServer();
const user = this.currentUser();
return !!room && !!user && (room.hostId === user.id || room.hostId === user.oderId || resolveRoomPermission(room, user, 'manageRoles'));
});
canManageServer = computed(() => {
const room = this.normalizedServer();
const user = this.currentUser();
return !!room && !!user && (room.hostId === user.id || room.hostId === user.oderId || resolveRoomPermission(room, user, 'manageServer'));
});
selectedRoleKey: string | null = null;
selectedChannelKey = '';
roleName = '';
roleColor = '#94a3b8';
constructor() {
effect(() => {
const room = this.normalizedServer();
const roles = this.roles();
const channels = this.channels();
if (!room || roles.length === 0) {
this.selectedRoleKey = null;
this.selectedChannelKey = '';
this.roleName = '';
this.roleColor = '#94a3b8';
return;
}
if (!this.selectedRoleKey || !roles.some((role) => role.id === this.selectedRoleKey)) {
this.selectedRoleKey = roles[0]?.id ?? null;
}
if (!this.selectedChannelKey || !channels.some((channel) => channel.id === this.selectedChannelKey)) {
this.selectedChannelKey = channels[0]?.id ?? '';
}
const selectedRole = roles.find((role) => role.id === this.selectedRoleKey) ?? null;
this.roleName = selectedRole?.name ?? '';
this.roleColor = selectedRole?.color ?? '#94a3b8';
});
}
loadPermissions(room: Room): void {
const normalizedRoom = normalizeRoomAccessControl(room);
this.selectedRoleKey = sortRolesForDisplay(normalizedRoom.roles ?? [])[0]?.id ?? null;
this.selectedChannelKey = normalizedRoom.channels?.[0]?.id ?? '';
}
selectedRole(): RoomRole | null {
return this.roles().find((role) => role.id === this.selectedRoleKey) ?? null;
}
selectedChannel() {
return this.channels().find((channel) => channel.id === this.selectedChannelKey) ?? null;
}
canEditSelectedRole(): boolean {
const room = this.normalizedServer();
const user = this.currentUser();
const role = this.selectedRole();
return !!room && !!user && !!role && canManageRole(room, user, role.id);
}
canEditSelectedRoleMetadata(): boolean {
const role = this.selectedRole();
return !!role && !role.isSystem && this.canEditSelectedRole();
}
selectRole(roleId: string): void {
this.selectedRoleKey = roleId;
}
selectChannel(channelId: string): void {
this.selectedChannelKey = channelId;
}
createRole(): void {
const room = this.normalizedServer();
if (!room || !this.canManageRoles())
return;
const role = createCustomRoomRole('New Role', room.roles ?? []);
this.selectedRoleKey = role.id;
this.roleName = role.name;
this.roleColor = role.color ?? '#94a3b8';
this.store.dispatch(
RoomsActions.updateRoomPermissions({
RoomsActions.updateRoomAccessControl({
roomId: room.id,
permissions: {
allowVoice: this.allowVoice,
allowScreenShare: this.allowScreenShare,
allowFileUploads: this.allowFileUploads,
slowModeInterval: parseInt(this.slowModeInterval, 10),
adminsManageRooms: this.adminsManageRooms,
moderatorsManageRooms: this.moderatorsManageRooms,
adminsManageIcon: this.adminsManageIcon,
moderatorsManageIcon: this.moderatorsManageIcon
changes: { roles: [...(room.roles ?? []), role] }
})
);
}
saveRoleDetails(): void {
const room = this.normalizedServer();
const role = this.selectedRole();
if (!room || !role || !this.canEditSelectedRoleMetadata())
return;
const roles = withUpdatedRole(room.roles ?? [], role.id, {
name: this.roleName.trim() || role.name,
color: this.roleColor.trim() || undefined
});
this.store.dispatch(
RoomsActions.updateRoomAccessControl({
roomId: room.id,
changes: { roles }
})
);
}
coercePermissionState(value: string): PermissionState {
return value === 'allow' || value === 'deny' || value === 'inherit' ? value : 'inherit';
}
slowModeValue(interval: number | undefined): string {
return String(interval ?? 0);
}
canMoveSelectedRoleUp(): boolean {
const role = this.selectedRole();
if (!role || role.isSystem || !this.canEditSelectedRoleMetadata()) {
return false;
}
return (
this.roles()
.filter((candidateRole) => !candidateRole.isSystem)
.findIndex((candidateRole) => candidateRole.id === role.id) > 0
);
}
canMoveSelectedRoleDown(): boolean {
const role = this.selectedRole();
const customRoles = this.roles().filter((candidateRole) => !candidateRole.isSystem);
if (!role || role.isSystem || !this.canEditSelectedRoleMetadata()) {
return false;
}
const index = customRoles.findIndex((candidateRole) => candidateRole.id === role.id);
return index >= 0 && index < customRoles.length - 1;
}
moveSelectedRole(direction: 'up' | 'down'): void {
const room = this.normalizedServer();
const role = this.selectedRole();
if (!room || !role || !this.canEditSelectedRoleMetadata())
return;
const orderedRoleIds = this.roles()
.filter((candidateRole) => !candidateRole.isSystem)
.map((candidateRole) => candidateRole.id);
const currentIndex = orderedRoleIds.findIndex((candidateRoleId) => candidateRoleId === role.id);
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= orderedRoleIds.length)
return;
const nextOrderedRoleIds = [...orderedRoleIds];
[nextOrderedRoleIds[currentIndex], nextOrderedRoleIds[targetIndex]] = [nextOrderedRoleIds[targetIndex], nextOrderedRoleIds[currentIndex]];
this.store.dispatch(
RoomsActions.updateRoomAccessControl({
roomId: room.id,
changes: { roles: reorderRoles(room.roles ?? [], nextOrderedRoleIds) }
})
);
}
deleteSelectedRole(): void {
const room = this.normalizedServer();
const role = this.selectedRole();
if (!room || !role || !this.canEditSelectedRoleMetadata())
return;
const nextState = removeRole(room.roles ?? [], room.roleAssignments, role.id);
this.store.dispatch(
RoomsActions.updateRoomAccessControl({
roomId: room.id,
changes: {
roles: nextState.roles,
roleAssignments: nextState.roleAssignments
}
})
);
this.showSaveSuccess('permissions');
}
private showSaveSuccess(key: string): void {
this.saveSuccess.set(key);
updateSlowMode(intervalValue: string): void {
const room = this.normalizedServer();
if (this.saveTimeout)
clearTimeout(this.saveTimeout);
if (!room || !this.canManageServer())
return;
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
this.store.dispatch(
RoomsActions.updateRoomAccessControl({
roomId: room.id,
changes: { slowModeInterval: Number(intervalValue) || 0 }
})
);
}
permissionState(permission: RoomPermissionKey): PermissionState {
return this.selectedRole()?.permissions?.[permission] ?? 'inherit';
}
setSelectedRolePermission(permission: RoomPermissionKey, value: PermissionState): void {
const room = this.normalizedServer();
const role = this.selectedRole();
if (!room || !role || !this.canEditSelectedRole())
return;
const roles = withUpdatedRole(room.roles ?? [], role.id, {
permissions: {
...role.permissions,
[permission]: value
}
});
this.store.dispatch(
RoomsActions.updateRoomAccessControl({
roomId: room.id,
changes: { roles }
})
);
}
channelOverrideState(permission: RoomPermissionKey): PermissionState {
const room = this.normalizedServer();
const role = this.selectedRole();
const channel = this.selectedChannel();
if (!room || !role || !channel) {
return 'inherit';
}
return (
room.channelPermissions?.find(
(override) =>
override.channelId === channel.id && override.targetType === 'role' && override.targetId === role.id && override.permission === permission
)?.value ?? 'inherit'
);
}
setChannelOverride(permission: RoomPermissionKey, value: PermissionState): void {
const room = this.normalizedServer();
const role = this.selectedRole();
const channel = this.selectedChannel();
if (!room || !role || !channel || !this.canEditSelectedRole())
return;
this.store.dispatch(
RoomsActions.updateRoomAccessControl({
roomId: room.id,
changes: {
channelPermissions: upsertRoleChannelOverride(room.channelPermissions, channel.id, role.id, permission, value)
}
})
);
}
trackRole(_: number, role: RoomRole): string {
return role.id;
}
}

View File

@@ -286,7 +286,7 @@
<app-permissions-settings
#permissionsComp
[server]="selectedServer()"
[isAdmin]="isSelectedServerOwner()"
[isAdmin]="canManageSelectedPermissions()"
/>
}
}

View File

@@ -32,8 +32,8 @@ import { RealtimeSessionFacade } from '../../../core/realtime';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { Room, UserRole } from '../../../shared-kernel';
import { findRoomMember } from '../../../store/rooms/room-members.helpers';
import { NotificationsSettingsComponent } from '../../../domains/notifications/feature/settings/notifications-settings.component';
import { resolveLegacyRole, resolveRoomPermission } from '../../../domains/access-control';
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
@@ -153,9 +153,16 @@ export class SettingsModalComponent {
return [];
return this.savedRooms().filter((room) => {
const role = this.getUserRoleForRoom(room, user.id, user.oderId, this.currentRoom()?.id === room.id ? user.role : null);
const viewedRoom = this.currentRoom()?.id === room.id ? this.currentRoom() ?? room : room;
const role = resolveLegacyRole(viewedRoom, user);
return role === 'host' || role === 'admin' || role === 'moderator';
return role === 'host'
|| resolveRoomPermission(viewedRoom, user, 'manageServer')
|| resolveRoomPermission(viewedRoom, user, 'manageRoles')
|| resolveRoomPermission(viewedRoom, user, 'manageChannels')
|| resolveRoomPermission(viewedRoom, user, 'manageBans')
|| resolveRoomPermission(viewedRoom, user, 'kickMembers')
|| resolveRoomPermission(viewedRoom, user, 'banMembers');
});
});
@@ -180,30 +187,55 @@ export class SettingsModalComponent {
if (!server || !user)
return null;
return this.getUserRoleForRoom(
server,
user.id,
user.oderId,
this.currentRoom()?.id === server.id ? user.role : null
);
return resolveLegacyRole(this.currentRoom()?.id === server.id ? this.currentRoom() ?? server : server, user);
});
canAccessSelectedServer = computed(() => {
const role = this.selectedServerRole();
const server = this.selectedServer();
const user = this.currentUser();
return role === 'host' || role === 'admin' || role === 'moderator';
return !!server && !!user && (
resolveLegacyRole(server, user) === 'host'
|| resolveRoomPermission(server, user, 'manageServer')
|| resolveRoomPermission(server, user, 'manageRoles')
|| resolveRoomPermission(server, user, 'manageChannels')
|| resolveRoomPermission(server, user, 'manageBans')
|| resolveRoomPermission(server, user, 'kickMembers')
|| resolveRoomPermission(server, user, 'banMembers')
);
});
canManageSelectedMembers = computed(() => {
const role = this.selectedServerRole();
const server = this.selectedServer();
const user = this.currentUser();
return role === 'host' || role === 'admin' || role === 'moderator';
return !!server && !!user && (
resolveLegacyRole(server, user) === 'host'
|| resolveRoomPermission(server, user, 'manageRoles')
|| resolveRoomPermission(server, user, 'kickMembers')
|| resolveRoomPermission(server, user, 'banMembers')
);
});
canManageSelectedBans = computed(() => {
const role = this.selectedServerRole();
const server = this.selectedServer();
const user = this.currentUser();
return role === 'host' || role === 'admin';
return !!server && !!user && (
resolveLegacyRole(server, user) === 'host'
|| resolveRoomPermission(server, user, 'manageBans')
);
});
canManageSelectedPermissions = computed(() => {
const server = this.selectedServer();
const user = this.currentUser();
return !!server && !!user && (
resolveLegacyRole(server, user) === 'host'
|| resolveRoomPermission(server, user, 'manageRoles')
|| resolveRoomPermission(server, user, 'manageServer')
);
});
isSelectedServerOwner = computed(() => {
@@ -283,23 +315,6 @@ export class SettingsModalComponent {
});
}
private getUserRoleForRoom(
room: Room,
userId: string,
userOderId: string,
currentRole: UserRole | null
): UserRole | null {
if (room.hostId === userId || room.hostId === userOderId)
return 'host';
if (currentRole)
return currentRole;
return findRoomMember(room.members ?? [], userId)?.role
|| findRoomMember(room.members ?? [], userOderId)?.role
|| null;
}
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.showThirdPartyLicenses()) {