Add access control rework
This commit is contained in:
@@ -1,438 +0,0 @@
|
||||
@if (isAdmin()) {
|
||||
<div class="h-full flex flex-col bg-card">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-border flex items-center gap-2">
|
||||
<ng-icon
|
||||
name="lucideShield"
|
||||
class="w-5 h-5 text-primary"
|
||||
/>
|
||||
<h2 class="font-semibold text-foreground">Admin Panel</h2>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
(click)="activeTab.set('settings')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'settings'"
|
||||
[class.border-b-2]="activeTab() === 'settings'"
|
||||
[class.border-primary]="activeTab() === 'settings'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'settings'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="activeTab.set('members')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'members'"
|
||||
[class.border-b-2]="activeTab() === 'members'"
|
||||
[class.border-primary]="activeTab() === 'members'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'members'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Members
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="activeTab.set('bans')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'bans'"
|
||||
[class.border-b-2]="activeTab() === 'bans'"
|
||||
[class.border-primary]="activeTab() === 'bans'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'bans'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideBan"
|
||||
class="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Bans
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="activeTab.set('permissions')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'permissions'"
|
||||
[class.border-b-2]="activeTab() === 'permissions'"
|
||||
[class.border-primary]="activeTab() === 'permissions'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'permissions'"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideShield"
|
||||
class="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Perms
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
@switch (activeTab()) {
|
||||
@case ('settings') {
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-sm font-medium text-foreground">Room Settings</h3>
|
||||
|
||||
<!-- Room Name -->
|
||||
<div>
|
||||
<label
|
||||
for="room-name-input"
|
||||
class="block text-sm text-muted-foreground mb-1"
|
||||
>Room Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="room-name-input"
|
||||
[(ngModel)]="roomName"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Room Description -->
|
||||
<div>
|
||||
<label
|
||||
for="room-description-input"
|
||||
class="block text-sm text-muted-foreground mb-1"
|
||||
>Description</label
|
||||
>
|
||||
<textarea
|
||||
id="room-description-input"
|
||||
[(ngModel)]="roomDescription"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Private Room Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Private Room</p>
|
||||
<p class="text-xs text-muted-foreground">Require approval to join</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="togglePrivate()"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
[class.bg-primary]="isPrivate()"
|
||||
[class.text-primary-foreground]="isPrivate()"
|
||||
[class.bg-secondary]="!isPrivate()"
|
||||
[class.text-muted-foreground]="!isPrivate()"
|
||||
>
|
||||
@if (isPrivate()) {
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideUnlock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Max Users -->
|
||||
<div>
|
||||
<label
|
||||
for="max-users-input"
|
||||
class="block text-sm text-muted-foreground mb-1"
|
||||
>Max Users (0 = unlimited)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="max-users-input"
|
||||
[(ngModel)]="maxUsers"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="saveSettings()"
|
||||
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"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Save Settings
|
||||
</button>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<h3 class="text-sm font-medium text-destructive mb-4">Danger Zone</h3>
|
||||
<button
|
||||
type="button"
|
||||
(click)="confirmDeleteRoom()"
|
||||
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('members') {
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
|
||||
|
||||
@if (membersFiltered().length === 0) {
|
||||
<p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
|
||||
} @else {
|
||||
@for (user of membersFiltered(); track user.id) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<app-user-avatar
|
||||
[name]="user.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">{{ user.displayName }}</p>
|
||||
@if (user.role === 'host') {
|
||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
|
||||
} @else if (user.role === 'admin') {
|
||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
|
||||
} @else if (user.role === 'moderator') {
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Role actions (only for non-hosts) -->
|
||||
@if (user.role !== 'host') {
|
||||
<div class="flex items-center gap-1">
|
||||
<select
|
||||
[ngModel]="user.role"
|
||||
(ngModelChange)="changeRole(user, $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>
|
||||
<button
|
||||
type="button"
|
||||
(click)="kickMember(user)"
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Kick"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUserX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="banMember(user)"
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Ban"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideBan"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('bans') {
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
|
||||
|
||||
@if (bannedUsers().length === 0) {
|
||||
<p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
|
||||
} @else {
|
||||
@for (ban of bannedUsers(); track ban.oderId) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<div class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm">
|
||||
{{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-foreground truncate">
|
||||
{{ ban.displayName || 'Unknown User' }}
|
||||
</p>
|
||||
@if (ban.reason) {
|
||||
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
|
||||
}
|
||||
@if (ban.expiresAt) {
|
||||
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
|
||||
} @else {
|
||||
<p class="text-xs text-destructive">Permanent</p>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="unbanUser(ban)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('permissions') {
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-foreground">Room Permissions</h3>
|
||||
|
||||
<!-- Permission Toggles -->
|
||||
<div class="space-y-3">
|
||||
<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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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 chat & voice rooms</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="adminsManageRooms"
|
||||
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 chat & voice rooms</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="moderatorsManageRooms"
|
||||
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"
|
||||
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"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Permissions -->
|
||||
<button
|
||||
type="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"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Save Permissions
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
@if (showDeleteConfirm()) {
|
||||
<app-confirm-dialog
|
||||
title="Delete Room"
|
||||
confirmLabel="Delete Room"
|
||||
variant="danger"
|
||||
[widthClass]="'w-96 max-w-[90vw]'"
|
||||
(confirmed)="deleteRoom()"
|
||||
(cancelled)="showDeleteConfirm.set(false)"
|
||||
>
|
||||
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
} @else {
|
||||
<div class="h-full flex items-center justify-center text-muted-foreground">
|
||||
<p>You don't have admin permissions</p>
|
||||
</div>
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideShield,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
selectBannedUsers,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectCurrentUser,
|
||||
selectOnlineUsers
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { BanEntry, User } from '../../../shared-kernel';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
|
||||
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideShield,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock
|
||||
})
|
||||
],
|
||||
templateUrl: './admin-panel.component.html'
|
||||
})
|
||||
/**
|
||||
* Admin panel for managing room settings, members, bans, and permissions.
|
||||
* Only accessible to users with admin privileges.
|
||||
*/
|
||||
export class AdminPanelComponent {
|
||||
store = inject(Store);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
bannedUsers = this.store.selectSignal(selectBannedUsers);
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
|
||||
activeTab = signal<AdminTab>('settings');
|
||||
showDeleteConfirm = signal(false);
|
||||
|
||||
// Settings
|
||||
roomName = '';
|
||||
roomDescription = '';
|
||||
isPrivate = signal(false);
|
||||
maxUsers = 0;
|
||||
|
||||
// Permissions
|
||||
allowVoice = true;
|
||||
allowScreenShare = true;
|
||||
allowFileUploads = true;
|
||||
slowModeInterval = '0';
|
||||
adminsManageRooms = false;
|
||||
moderatorsManageRooms = false;
|
||||
adminsManageIcon = false;
|
||||
moderatorsManageIcon = false;
|
||||
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
|
||||
constructor() {
|
||||
// Initialize from current room
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (room) {
|
||||
this.roomName = room.name;
|
||||
this.roomDescription = room.description || '';
|
||||
this.isPrivate.set(room.isPrivate);
|
||||
this.maxUsers = room.maxUsers || 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the room's private visibility setting. */
|
||||
togglePrivate(): void {
|
||||
this.isPrivate.update((current) => !current);
|
||||
}
|
||||
|
||||
/** Save the current room name, description, privacy, and max-user settings. */
|
||||
saveSettings(): void {
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoomSettings({
|
||||
roomId: room.id,
|
||||
settings: {
|
||||
name: this.roomName,
|
||||
description: this.roomDescription,
|
||||
isPrivate: this.isPrivate(),
|
||||
maxUsers: this.maxUsers
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Persist updated room permissions (voice, screen-share, uploads, slow-mode, role grants). */
|
||||
savePermissions(): void {
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoomPermissions({
|
||||
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
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Remove a user's ban entry. */
|
||||
unbanUser(ban: BanEntry): void {
|
||||
this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId,
|
||||
oderId: ban.oderId }));
|
||||
}
|
||||
|
||||
/** Show the delete-room confirmation dialog. */
|
||||
confirmDeleteRoom(): void {
|
||||
this.showDeleteConfirm.set(true);
|
||||
}
|
||||
|
||||
/** Delete the current room after confirmation. */
|
||||
deleteRoom(): void {
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
|
||||
this.showDeleteConfirm.set(false);
|
||||
}
|
||||
|
||||
/** Format a ban expiry timestamp into a human-readable date/time string. */
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit',
|
||||
minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Members tab: get all users except self
|
||||
/** Return online users excluding the current user (for the members list). */
|
||||
membersFiltered(): User[] {
|
||||
const me = this.currentUser();
|
||||
|
||||
return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId);
|
||||
}
|
||||
|
||||
/** Change a member's role and notify connected peers. */
|
||||
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
||||
const roomId = this.currentRoom()?.id;
|
||||
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
|
||||
role }));
|
||||
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'role-change',
|
||||
roomId,
|
||||
targetUserId: user.id,
|
||||
role
|
||||
});
|
||||
}
|
||||
|
||||
/** Kick a member from the server. */
|
||||
kickMember(user: User): void {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
}
|
||||
|
||||
/** Ban a member from the server. */
|
||||
banMember(user: User): void {
|
||||
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
|
||||
}
|
||||
}
|
||||
@@ -22,17 +22,11 @@ import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messag
|
||||
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
|
||||
import { VoiceWorkspaceComponent } from '../voice-workspace/voice-workspace.component';
|
||||
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
selectTextChannels
|
||||
} from '../../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentRoom, selectTextChannels } from '../../../store/rooms/rooms.selectors';
|
||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import {
|
||||
ThemeNodeDirective,
|
||||
ThemeService
|
||||
} from '../../../domains/theme';
|
||||
import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-room',
|
||||
|
||||
@@ -440,8 +440,8 @@
|
||||
[y]="userMenuY()"
|
||||
(closed)="closeUserMenu()"
|
||||
>
|
||||
@if (isAdmin()) {
|
||||
@if (contextMenuUser()?.role === 'member') {
|
||||
@if (contextMenuUser(); as selectedUser) {
|
||||
@if (canChangeUserRole(selectedUser) && selectedUser.role === 'member') {
|
||||
<button
|
||||
(click)="changeUserRole('moderator')"
|
||||
class="context-menu-item"
|
||||
@@ -455,7 +455,7 @@
|
||||
Promote to Admin
|
||||
</button>
|
||||
}
|
||||
@if (contextMenuUser()?.role === 'moderator') {
|
||||
@if (canChangeUserRole(selectedUser) && selectedUser.role === 'moderator') {
|
||||
<button
|
||||
(click)="changeUserRole('admin')"
|
||||
class="context-menu-item"
|
||||
@@ -469,7 +469,7 @@
|
||||
Demote to Member
|
||||
</button>
|
||||
}
|
||||
@if (contextMenuUser()?.role === 'admin') {
|
||||
@if (canChangeUserRole(selectedUser) && selectedUser.role === 'admin') {
|
||||
<button
|
||||
(click)="changeUserRole('member')"
|
||||
class="context-menu-item"
|
||||
@@ -477,15 +477,20 @@
|
||||
Demote to Member
|
||||
</button>
|
||||
}
|
||||
<div class="context-menu-divider"></div>
|
||||
<button
|
||||
(click)="kickUserAction()"
|
||||
class="context-menu-item-danger"
|
||||
>
|
||||
Kick User
|
||||
</button>
|
||||
} @else {
|
||||
<div class="context-menu-empty">No actions available</div>
|
||||
@if (canChangeUserRole(selectedUser) && canKickUser(selectedUser)) {
|
||||
<div class="context-menu-divider"></div>
|
||||
}
|
||||
@if (canKickUser(selectedUser)) {
|
||||
<button
|
||||
(click)="kickUserAction()"
|
||||
class="context-menu-item-danger"
|
||||
>
|
||||
Kick User
|
||||
</button>
|
||||
}
|
||||
@if (!canChangeUserRole(selectedUser) && !canKickUser(selectedUser)) {
|
||||
<div class="context-menu-empty">No actions available</div>
|
||||
}
|
||||
}
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -25,17 +25,11 @@ import {
|
||||
import { Room, User } from '../../shared-kernel';
|
||||
import { VoiceSessionFacade } from '../../domains/voice-session';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
selectCurrentUser,
|
||||
selectOnlineUsers
|
||||
} from '../../store/users/users.selectors';
|
||||
import { selectCurrentUser, selectOnlineUsers } from '../../store/users/users.selectors';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { NotificationsFacade } from '../../domains/notifications';
|
||||
import {
|
||||
type ServerInfo,
|
||||
ServerDirectoryFacade
|
||||
} from '../../domains/server-directory';
|
||||
import { type ServerInfo, ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||
import {
|
||||
ConfirmDialogComponent,
|
||||
@@ -89,7 +83,6 @@ export class ServersRailComponent {
|
||||
voicePresenceByRoom = computed(() => {
|
||||
const presence: Record<string, number> = {};
|
||||
const seenByRoom = new Map<string, Set<string>>();
|
||||
|
||||
const addVoicePresence = (user: User | null | undefined): void => {
|
||||
if (!user) {
|
||||
return;
|
||||
@@ -103,6 +96,7 @@ export class ServersRailComponent {
|
||||
}
|
||||
|
||||
const userKey = user.oderId || user.id;
|
||||
|
||||
let seenUsers = seenByRoom.get(roomId);
|
||||
|
||||
if (!seenUsers) {
|
||||
@@ -344,15 +338,15 @@ export class ServersRailComponent {
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
return this.serverDirectory.requestJoin({
|
||||
roomId: room.id,
|
||||
userId: currentUserId,
|
||||
userPublicKey: currentUser?.oderId || currentUserId,
|
||||
displayName: currentUser?.displayName || 'Anonymous',
|
||||
password: password?.trim() || undefined
|
||||
}, {
|
||||
sourceId: room.sourceId,
|
||||
sourceUrl: room.sourceUrl
|
||||
})
|
||||
roomId: room.id,
|
||||
userId: currentUserId,
|
||||
userPublicKey: currentUser?.oderId || currentUserId,
|
||||
displayName: currentUser?.displayName || 'Anonymous',
|
||||
password: password?.trim() || undefined
|
||||
}, {
|
||||
sourceId: room.sourceId,
|
||||
sourceUrl: room.sourceUrl
|
||||
})
|
||||
.pipe(
|
||||
tap((response) => {
|
||||
this.closePasswordDialog();
|
||||
@@ -395,6 +389,7 @@ export class ServersRailComponent {
|
||||
...lookup,
|
||||
[room.id]: true
|
||||
}));
|
||||
|
||||
this.bannedServerName.set(room.name);
|
||||
this.showBannedDialog.set(true);
|
||||
return;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@
|
||||
<app-permissions-settings
|
||||
#permissionsComp
|
||||
[server]="selectedServer()"
|
||||
[isAdmin]="isSelectedServerOwner()"
|
||||
[isAdmin]="canManageSelectedPermissions()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user