Add access control rework
This commit is contained in:
@@ -96,8 +96,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "1MB",
|
||||
"maximumError": "2.15MB"
|
||||
"maximumWarning": "2.2MB",
|
||||
"maximumError": "2.3MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
@if (isThemeStudioFullscreen()) {
|
||||
<div class="theme-studio-fullscreen-shell absolute inset-0 overflow-y-auto overflow-x-hidden bg-background">
|
||||
@if (themeStudioFullscreenComponent()) {
|
||||
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()"></ng-container>
|
||||
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()" />
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio…</div>
|
||||
}
|
||||
|
||||
@@ -40,10 +40,7 @@ import { ScreenShareSourcePickerComponent } from './shared/components/screen-sha
|
||||
import { UsersActions } from './store/users/users.actions';
|
||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
import {
|
||||
ROOM_URL_PATTERN,
|
||||
STORAGE_KEY_CURRENT_USER_ID
|
||||
} from './core/constants';
|
||||
import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID } from './core/constants';
|
||||
import {
|
||||
ThemeNodeDirective,
|
||||
ThemePickerOverlayComponent,
|
||||
@@ -241,7 +238,6 @@ export class App implements OnInit, OnDestroy {
|
||||
this.router.events.subscribe((evt) => {
|
||||
if (evt instanceof NavigationEnd) {
|
||||
const url = evt.urlAfterRedirects || evt.url;
|
||||
|
||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
||||
|
||||
@@ -274,14 +270,17 @@ export class App implements OnInit, OnDestroy {
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
};
|
||||
|
||||
this.themeStudioControlsDragOffset = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top
|
||||
};
|
||||
|
||||
this.themeStudioControlsPosition.set({
|
||||
x: rect.left,
|
||||
y: rect.top
|
||||
});
|
||||
|
||||
this.isDraggingThemeStudioControls.set(true);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
@@ -6,23 +6,25 @@ infrastructure adapters and UI.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Domain | Purpose | Public entry point |
|
||||
|---|---|---|
|
||||
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
|
||||
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
|
||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
|
||||
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
|
||||
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
|
||||
| Domain | Purpose | Public entry point |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
|
||||
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
|
||||
| **access-control** | Role, permission, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()` |
|
||||
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
|
||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
|
||||
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
|
||||
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
|
||||
|
||||
## Detailed docs
|
||||
|
||||
The larger domains also keep longer design notes in their own folders:
|
||||
|
||||
- [attachment/README.md](attachment/README.md)
|
||||
- [access-control/README.md](access-control/README.md)
|
||||
- [auth/README.md](auth/README.md)
|
||||
- [chat/README.md](chat/README.md)
|
||||
- [notifications/README.md](notifications/README.md)
|
||||
@@ -66,12 +68,12 @@ domains/<name>/
|
||||
|
||||
## Where do I put new code?
|
||||
|
||||
| I want to… | Put it in… |
|
||||
|---|---|
|
||||
| Add a new business concept | New folder under `domains/` following the convention above |
|
||||
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
|
||||
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
|
||||
| Add a settings subpanel | `domains/<name>/feature/settings/` |
|
||||
| Add a top-level page or shell component | `features/` |
|
||||
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
|
||||
| Add realtime/WebRTC logic | `infrastructure/realtime/` |
|
||||
| I want to… | Put it in… |
|
||||
| --------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Add a new business concept | New folder under `domains/` following the convention above |
|
||||
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
|
||||
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
|
||||
| Add a settings subpanel | `domains/<name>/feature/settings/` |
|
||||
| Add a top-level page or shell component | `features/` |
|
||||
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
|
||||
| Add realtime/WebRTC logic | `infrastructure/realtime/` |
|
||||
|
||||
37
toju-app/src/app/domains/access-control/README.md
Normal file
37
toju-app/src/app/domains/access-control/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Access Control Domain
|
||||
|
||||
Role and permission rules for servers, including default system roles, role assignment normalization, permission resolution, legacy compatibility mapping, and room-level access-control hydration.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
access-control/
|
||||
├── domain/
|
||||
│ ├── access-control.models.ts MemberIdentity and RoomPermissionDefinition domain types
|
||||
│ ├── access-control.constants.ts SYSTEM_ROLE_IDS and permission metadata
|
||||
│ ├── role.rules.ts Role defaults, normalization, ordering, create/update helpers
|
||||
│ ├── role-assignment.rules.ts Assignment normalization and member-role lookups
|
||||
│ ├── permission.rules.ts Permission resolution and moderation hierarchy checks
|
||||
│ ├── room.rules.ts Legacy compatibility, room hydration, room-level normalization
|
||||
│ └── access-control.logic.ts Public barrel for domain rules
|
||||
│
|
||||
└── index.ts Domain barrel used by other layers
|
||||
```
|
||||
|
||||
## Domain rules
|
||||
|
||||
| Function | Purpose |
|
||||
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `normalizeRoomRoles(room.roles, room.permissions)` | Repairs missing/default roles and keeps role ordering stable |
|
||||
| `normalizeRoomRoleAssignments(...)` | Deduplicates and backfills member role assignments from legacy member role fields |
|
||||
| `normalizeChannelPermissionOverrides(...)` | Deduplicates valid channel overrides and drops invalid references |
|
||||
| `resolveRoomPermission(room, identity, permission, channelId?)` | Resolves effective permission state including overrides |
|
||||
| `canManageMember(...)` | Applies both permission checks and role hierarchy checks |
|
||||
| `canManageRole(...)` | Prevents editing roles at or above the actor's highest role |
|
||||
| `normalizeRoomAccessControl(room)` | Produces a fully hydrated room with normalized roles, assignments, overrides, and legacy compatibility fields |
|
||||
|
||||
## Layering
|
||||
|
||||
- Domain rules stay pure and only depend on `shared-kernel` contracts plus other files in this domain.
|
||||
- Renderer shells and NgRx effects should keep importing from `src/app/domains/access-control/` instead of internal files.
|
||||
- Legacy `room.permissions` booleans remain compatibility output only; normalized data lives on `roles`, `roleAssignments`, `channelPermissions`, and `slowModeInterval`.
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { RoomPermissionDefinition } from './access-control.models';
|
||||
|
||||
export const SYSTEM_ROLE_IDS = {
|
||||
everyone: 'system-everyone',
|
||||
moderator: 'system-moderator',
|
||||
admin: 'system-admin'
|
||||
} as const;
|
||||
|
||||
export const ROOM_PERMISSION_DEFINITIONS: RoomPermissionDefinition[] = [
|
||||
{
|
||||
key: 'manageServer',
|
||||
label: 'Manage Server',
|
||||
description: 'Edit server settings such as name, privacy, and limits.'
|
||||
},
|
||||
{
|
||||
key: 'manageRoles',
|
||||
label: 'Manage Roles',
|
||||
description: 'Create, edit, reorder, and assign roles.'
|
||||
},
|
||||
{
|
||||
key: 'manageChannels',
|
||||
label: 'Manage Channels',
|
||||
description: 'Create, rename, delete, and reorder channels.'
|
||||
},
|
||||
{
|
||||
key: 'manageIcon',
|
||||
label: 'Manage Icon',
|
||||
description: 'Change the server icon for all members.'
|
||||
},
|
||||
{
|
||||
key: 'kickMembers',
|
||||
label: 'Kick Members',
|
||||
description: 'Remove members from the server without banning them.'
|
||||
},
|
||||
{
|
||||
key: 'banMembers',
|
||||
label: 'Ban Members',
|
||||
description: 'Ban members from the server.'
|
||||
},
|
||||
{
|
||||
key: 'manageBans',
|
||||
label: 'Manage Bans',
|
||||
description: 'Review and revoke existing bans.'
|
||||
},
|
||||
{
|
||||
key: 'deleteMessages',
|
||||
label: 'Delete Messages',
|
||||
description: 'Delete messages sent by other members.'
|
||||
},
|
||||
{
|
||||
key: 'joinVoice',
|
||||
label: 'Join Voice',
|
||||
description: 'Join voice channels.'
|
||||
},
|
||||
{
|
||||
key: 'shareScreen',
|
||||
label: 'Share Screen',
|
||||
description: 'Start screen sharing in voice channels.'
|
||||
},
|
||||
{
|
||||
key: 'uploadFiles',
|
||||
label: 'Upload Files',
|
||||
description: 'Upload attachments in chat.'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
PermissionState,
|
||||
RoomPermissionKey,
|
||||
RoomPermissionMatrix,
|
||||
RoomRole,
|
||||
RoomRoleAssignment,
|
||||
ROOM_PERMISSION_KEYS
|
||||
} from '../../../shared-kernel';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
|
||||
export function normalizeName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function compareText(firstValue: string, secondValue: string): number {
|
||||
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export function uniqueStrings(values: readonly string[]): string[] {
|
||||
return Array.from(new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0).map((value) => value.trim())));
|
||||
}
|
||||
|
||||
export function normalizePermissionState(value: unknown): PermissionState {
|
||||
return value === 'allow' || value === 'deny' || value === 'inherit' ? value : 'inherit';
|
||||
}
|
||||
|
||||
export function normalizePermissionMatrix(matrix: RoomPermissionMatrix | undefined): RoomPermissionMatrix {
|
||||
const normalized: RoomPermissionMatrix = {};
|
||||
|
||||
for (const key of ROOM_PERMISSION_KEYS) {
|
||||
const value = normalizePermissionState(matrix?.[key]);
|
||||
|
||||
if (value !== 'inherit') {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function memberIdentityKey(identity: MemberIdentity | null | undefined): string {
|
||||
return identity?.oderId?.trim() || identity?.id?.trim() || '';
|
||||
}
|
||||
|
||||
export function matchesIdentity(identity: MemberIdentity | null | undefined, candidate: Pick<RoomRoleAssignment, 'userId' | 'oderId'>): boolean {
|
||||
if (!identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!(
|
||||
(identity.id && (candidate.userId === identity.id || candidate.oderId === identity.id)) ||
|
||||
(identity.oderId && (candidate.userId === identity.oderId || candidate.oderId === identity.oderId))
|
||||
);
|
||||
}
|
||||
|
||||
export function roleSortAscending(firstRole: RoomRole, secondRole: RoomRole): number {
|
||||
if (firstRole.position !== secondRole.position) {
|
||||
return firstRole.position - secondRole.position;
|
||||
}
|
||||
|
||||
return compareText(firstRole.name, secondRole.name);
|
||||
}
|
||||
|
||||
export function roleSortDescending(firstRole: RoomRole, secondRole: RoomRole): number {
|
||||
return roleSortAscending(secondRole, firstRole);
|
||||
}
|
||||
|
||||
export function permissionStateToBoolean(value: PermissionState | undefined, fallbackValue: boolean): boolean {
|
||||
if (value === 'allow') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value === 'deny') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
export function getRolePermissionState(role: RoomRole | undefined, permission: RoomPermissionKey): PermissionState {
|
||||
return normalizePermissionState(role?.permissions?.[permission]);
|
||||
}
|
||||
|
||||
export function buildSystemRole(id: string, name: string, position: number, permissions: RoomPermissionMatrix, color: string): RoomRole {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
position,
|
||||
color,
|
||||
isSystem: true,
|
||||
permissions: normalizePermissionMatrix(permissions)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRoleLookup(roles: readonly RoomRole[]): Map<string, RoomRole> {
|
||||
return new Map(roles.map((role) => [role.id, role]));
|
||||
}
|
||||
|
||||
export function nextRolePosition(roles: readonly RoomRole[]): number {
|
||||
if (roles.length === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.max(...roles.map((role) => role.position)) + 100;
|
||||
}
|
||||
|
||||
export function resolveLegacyAllowState(
|
||||
value: boolean | undefined,
|
||||
currentState: PermissionState | undefined,
|
||||
disabledState: Exclude<PermissionState, 'allow'>
|
||||
): PermissionState | undefined {
|
||||
if (value === undefined) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return value ? 'allow' : disabledState;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './access-control.models';
|
||||
export * from './access-control.constants';
|
||||
export * from './role.rules';
|
||||
export * from './role-assignment.rules';
|
||||
export * from './permission.rules';
|
||||
export * from './room.rules';
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
RoomMember,
|
||||
RoomPermissionKey,
|
||||
User
|
||||
} from '../../../shared-kernel';
|
||||
|
||||
export interface RoomPermissionDefinition {
|
||||
key: RoomPermissionKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type MemberIdentity = Pick<RoomMember, 'id' | 'oderId'> | Pick<User, 'id' | 'oderId'> | { id?: string; oderId?: string };
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
ChannelPermissionOverride,
|
||||
PermissionState,
|
||||
Room,
|
||||
RoomPermissionKey,
|
||||
RoomRole
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
getRolePermissionState,
|
||||
matchesIdentity,
|
||||
normalizePermissionState,
|
||||
roleSortAscending,
|
||||
compareText
|
||||
} from './access-control.internal';
|
||||
import { getAssignedRoleIds, getHighestAssignedRole } from './role-assignment.rules';
|
||||
import { getRoomRoleById, normalizeRoomRoles } from './role.rules';
|
||||
|
||||
function resolveRolePermissionState(roles: readonly RoomRole[], assignedRoleIds: readonly string[], permission: RoomPermissionKey): PermissionState {
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
const assignedRoles = assignedRoleIds.map((roleId) => roleLookup.get(roleId)).filter((role): role is RoomRole => !!role);
|
||||
const effectiveRoles = [roleLookup.get(SYSTEM_ROLE_IDS.everyone), ...assignedRoles]
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortAscending);
|
||||
|
||||
let state: PermissionState = 'inherit';
|
||||
|
||||
for (const role of effectiveRoles) {
|
||||
const nextState = getRolePermissionState(role, permission);
|
||||
|
||||
if (nextState !== 'inherit') {
|
||||
state = nextState;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function resolveChannelOverrideState(
|
||||
overrides: readonly ChannelPermissionOverride[],
|
||||
roles: readonly RoomRole[],
|
||||
assignedRoleIds: readonly string[],
|
||||
identity: MemberIdentity,
|
||||
channelId: string,
|
||||
permission: RoomPermissionKey,
|
||||
baseState: PermissionState
|
||||
): PermissionState {
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
|
||||
let state = baseState;
|
||||
|
||||
const everyoneOverride = overrides.find(
|
||||
(override) =>
|
||||
override.channelId === channelId &&
|
||||
override.targetType === 'role' &&
|
||||
override.targetId === SYSTEM_ROLE_IDS.everyone &&
|
||||
override.permission === permission
|
||||
);
|
||||
|
||||
if (everyoneOverride?.value && everyoneOverride.value !== 'inherit') {
|
||||
state = everyoneOverride.value;
|
||||
}
|
||||
|
||||
const orderedAssignedRoles = assignedRoleIds
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortAscending);
|
||||
|
||||
for (const role of orderedAssignedRoles) {
|
||||
const override = overrides.find(
|
||||
(candidateOverride) =>
|
||||
candidateOverride.channelId === channelId &&
|
||||
candidateOverride.targetType === 'role' &&
|
||||
candidateOverride.targetId === role.id &&
|
||||
candidateOverride.permission === permission
|
||||
);
|
||||
|
||||
if (override?.value && override.value !== 'inherit') {
|
||||
state = override.value;
|
||||
}
|
||||
}
|
||||
|
||||
const userOverride = overrides.find(
|
||||
(override) =>
|
||||
override.channelId === channelId &&
|
||||
override.targetType === 'user' &&
|
||||
override.permission === permission &&
|
||||
(override.targetId === identity.id || override.targetId === identity.oderId)
|
||||
);
|
||||
|
||||
if (userOverride?.value && userOverride.value !== 'inherit') {
|
||||
state = userOverride.value;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function normalizeChannelPermissionOverrides(
|
||||
overrides: readonly ChannelPermissionOverride[] | undefined,
|
||||
roles: readonly RoomRole[]
|
||||
): ChannelPermissionOverride[] {
|
||||
const validRoleIds = new Set(roles.map((role) => role.id));
|
||||
const normalizedByKey = new Map<string, ChannelPermissionOverride>();
|
||||
|
||||
for (const override of overrides ?? []) {
|
||||
if (!override || typeof override !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const channelId = typeof override.channelId === 'string' ? override.channelId.trim() : '';
|
||||
const targetId = typeof override.targetId === 'string' ? override.targetId.trim() : '';
|
||||
const targetType = override.targetType === 'role' || override.targetType === 'user' ? override.targetType : null;
|
||||
const permission = override.permission;
|
||||
const value = normalizePermissionState(override.value);
|
||||
|
||||
if (!channelId || !targetId || !targetType || !permission || value === 'inherit') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (targetType === 'role' && !validRoleIds.has(targetId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedByKey.set(`${channelId}:${targetType}:${targetId}:${permission}`, {
|
||||
channelId,
|
||||
targetType,
|
||||
targetId,
|
||||
permission,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(normalizedByKey.values()).sort((firstOverride, secondOverride) => {
|
||||
const channelCompare = compareText(firstOverride.channelId, secondOverride.channelId);
|
||||
|
||||
if (channelCompare !== 0) {
|
||||
return channelCompare;
|
||||
}
|
||||
|
||||
if (firstOverride.targetType !== secondOverride.targetType) {
|
||||
return compareText(firstOverride.targetType, secondOverride.targetType);
|
||||
}
|
||||
|
||||
const targetCompare = compareText(firstOverride.targetId, secondOverride.targetId);
|
||||
|
||||
if (targetCompare !== 0) {
|
||||
return targetCompare;
|
||||
}
|
||||
|
||||
return compareText(firstOverride.permission, secondOverride.permission);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveRoomPermission(
|
||||
room: Room,
|
||||
identity: MemberIdentity | null | undefined,
|
||||
permission: RoomPermissionKey,
|
||||
channelId?: string
|
||||
): boolean {
|
||||
if (!identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (room.hostId === identity.id || room.hostId === identity.oderId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const assignedRoleIds = getAssignedRoleIds(room.roleAssignments, identity);
|
||||
const roleState = resolveRolePermissionState(roles, assignedRoleIds, permission);
|
||||
const channelState = channelId
|
||||
? resolveChannelOverrideState(
|
||||
normalizeChannelPermissionOverrides(room.channelPermissions, roles),
|
||||
roles,
|
||||
assignedRoleIds,
|
||||
identity,
|
||||
channelId,
|
||||
permission,
|
||||
roleState
|
||||
)
|
||||
: roleState;
|
||||
|
||||
return channelState === 'allow';
|
||||
}
|
||||
|
||||
export function canManageMember(
|
||||
room: Room,
|
||||
actor: MemberIdentity | null | undefined,
|
||||
target: MemberIdentity | null | undefined,
|
||||
permission: 'kickMembers' | 'banMembers' | 'manageRoles'
|
||||
): boolean {
|
||||
if (!actor || !target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isActorOwner = room.hostId === actor.id || room.hostId === actor.oderId;
|
||||
const isTargetOwner = room.hostId === target.id || room.hostId === target.oderId;
|
||||
const isSameIdentity = matchesIdentity(actor, {
|
||||
userId: target.id || target.oderId || '',
|
||||
oderId: target.oderId
|
||||
});
|
||||
|
||||
if (isSameIdentity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTargetOwner && !isActorOwner) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isActorOwner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resolveRoomPermission(room, actor, permission)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actorRole = getHighestAssignedRole(room, actor);
|
||||
const targetRole = getHighestAssignedRole(room, target);
|
||||
|
||||
return (actorRole?.position ?? 0) > (targetRole?.position ?? 0);
|
||||
}
|
||||
|
||||
export function canManageRole(room: Room, actor: MemberIdentity | null | undefined, roleId: string): boolean {
|
||||
if (!actor || !roleId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (room.hostId === actor.id || room.hostId === actor.oderId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resolveRoomPermission(room, actor, 'manageRoles')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetRole = getRoomRoleById(normalizeRoomRoles(room.roles, room.permissions), roleId);
|
||||
const actorRole = getHighestAssignedRole(room, actor);
|
||||
|
||||
if (!targetRole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (actorRole?.position ?? 0) > targetRole.position;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomRole,
|
||||
RoomRoleAssignment
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
compareText,
|
||||
memberIdentityKey,
|
||||
matchesIdentity,
|
||||
roleSortDescending,
|
||||
uniqueStrings
|
||||
} from './access-control.internal';
|
||||
import { getRoomRoleById, normalizeRoomRoles } from './role.rules';
|
||||
|
||||
function sortAssignments(assignments: readonly RoomRoleAssignment[]): RoomRoleAssignment[] {
|
||||
return [...assignments].sort((firstAssignment, secondAssignment) =>
|
||||
compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLegacyMemberRoleIds(member: RoomMember, validRoleIds: Set<string>): string[] {
|
||||
if (Array.isArray(member.roleIds) && member.roleIds.length > 0) {
|
||||
return uniqueStrings(member.roleIds).filter((roleId) => validRoleIds.has(roleId));
|
||||
}
|
||||
|
||||
if (member.role === 'admin') {
|
||||
return [SYSTEM_ROLE_IDS.admin];
|
||||
}
|
||||
|
||||
if (member.role === 'moderator') {
|
||||
return [SYSTEM_ROLE_IDS.moderator];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeRoomRoleAssignments(
|
||||
assignments: readonly RoomRoleAssignment[] | undefined,
|
||||
members: readonly RoomMember[] | undefined,
|
||||
roles: readonly RoomRole[]
|
||||
): RoomRoleAssignment[] {
|
||||
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
|
||||
const normalizedByUserKey = new Map<string, RoomRoleAssignment>();
|
||||
|
||||
for (const assignment of assignments ?? []) {
|
||||
if (!assignment || typeof assignment !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const userId = typeof assignment.userId === 'string' ? assignment.userId.trim() : '';
|
||||
const oderId = typeof assignment.oderId === 'string' ? assignment.oderId.trim() : undefined;
|
||||
const key = oderId || userId;
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const roleIds = uniqueStrings(assignment.roleIds ?? []).filter((roleId) => validRoleIds.has(roleId));
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedByUserKey.set(key, {
|
||||
userId: userId || key,
|
||||
oderId,
|
||||
roleIds
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedByUserKey.size > 0) {
|
||||
return sortAssignments(Array.from(normalizedByUserKey.values()));
|
||||
}
|
||||
|
||||
for (const member of members ?? []) {
|
||||
const key = memberIdentityKey(member);
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const roleIds = resolveLegacyMemberRoleIds(member, validRoleIds);
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedByUserKey.set(key, {
|
||||
userId: member.id || key,
|
||||
oderId: member.oderId || undefined,
|
||||
roleIds
|
||||
});
|
||||
}
|
||||
|
||||
return sortAssignments(Array.from(normalizedByUserKey.values()));
|
||||
}
|
||||
|
||||
export function getAssignedRoleIds(assignments: readonly RoomRoleAssignment[] | undefined, identity: MemberIdentity | null | undefined): string[] {
|
||||
const assignment = (assignments ?? []).find((candidateAssignment) => matchesIdentity(identity, candidateAssignment));
|
||||
|
||||
return uniqueStrings(assignment?.roleIds ?? []);
|
||||
}
|
||||
|
||||
export function getDisplayRoleName(room: Room, member: MemberIdentity | null | undefined): string {
|
||||
if (!member) {
|
||||
return 'Member';
|
||||
}
|
||||
|
||||
if (room.hostId === member.id || room.hostId === member.oderId) {
|
||||
return 'Owner';
|
||||
}
|
||||
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
const assignedRoles = getAssignedRoleIds(room.roleAssignments, member)
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortDescending);
|
||||
|
||||
return assignedRoles[0]?.name || '@everyone';
|
||||
}
|
||||
|
||||
export function getAssignedRoles(room: Room, identity: MemberIdentity | null | undefined): RoomRole[] {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
|
||||
return getAssignedRoleIds(room.roleAssignments, identity)
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortDescending);
|
||||
}
|
||||
|
||||
export function getHighestAssignedRole(room: Room, identity: MemberIdentity | null | undefined): RoomRole | null {
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (room.hostId === identity.id || room.hostId === identity.oderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getAssignedRoles(room, identity)[0] ?? getRoomRoleById(normalizeRoomRoles(room.roles, room.permissions), SYSTEM_ROLE_IDS.everyone) ?? null;
|
||||
}
|
||||
|
||||
export function setRoleAssignmentsForMember(
|
||||
assignments: readonly RoomRoleAssignment[] | undefined,
|
||||
member: MemberIdentity,
|
||||
roleIds: readonly string[]
|
||||
): RoomRoleAssignment[] {
|
||||
const nextAssignments = new Map<string, RoomRoleAssignment>();
|
||||
const memberKey = memberIdentityKey(member);
|
||||
|
||||
for (const assignment of assignments ?? []) {
|
||||
const key = memberIdentityKey({
|
||||
id: assignment.userId,
|
||||
oderId: assignment.oderId
|
||||
});
|
||||
|
||||
if (!key || key === memberKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextAssignments.set(key, {
|
||||
userId: assignment.userId,
|
||||
oderId: assignment.oderId,
|
||||
roleIds: uniqueStrings(assignment.roleIds)
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRoleIds = uniqueStrings(roleIds);
|
||||
|
||||
if (memberKey && normalizedRoleIds.length > 0) {
|
||||
nextAssignments.set(memberKey, {
|
||||
userId: member.id || member.oderId || memberKey,
|
||||
oderId: member.oderId || undefined,
|
||||
roleIds: normalizedRoleIds
|
||||
});
|
||||
}
|
||||
|
||||
return sortAssignments(Array.from(nextAssignments.values()));
|
||||
}
|
||||
|
||||
export function removeRoleFromAssignments(assignments: readonly RoomRoleAssignment[] | undefined, roleId: string): RoomRoleAssignment[] {
|
||||
return (assignments ?? [])
|
||||
.map((assignment) => ({
|
||||
...assignment,
|
||||
roleIds: assignment.roleIds.filter((candidateRoleId) => candidateRoleId !== roleId)
|
||||
}))
|
||||
.filter((assignment) => assignment.roleIds.length > 0);
|
||||
}
|
||||
|
||||
export function getRoleIdsForMember(room: Room, member: MemberIdentity | null | undefined): string[] {
|
||||
return getAssignedRoleIds(
|
||||
normalizeRoomRoleAssignments(room.roleAssignments, room.members, normalizeRoomRoles(room.roles, room.permissions)),
|
||||
member
|
||||
);
|
||||
}
|
||||
171
toju-app/src/app/domains/access-control/domain/role.rules.ts
Normal file
171
toju-app/src/app/domains/access-control/domain/role.rules.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
RoomPermissionMatrix,
|
||||
RoomPermissions,
|
||||
RoomRole
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
buildSystemRole,
|
||||
nextRolePosition,
|
||||
normalizeName,
|
||||
normalizePermissionMatrix,
|
||||
roleSortAscending,
|
||||
roleSortDescending
|
||||
} from './access-control.internal';
|
||||
|
||||
const ROLE_COLORS = {
|
||||
everyone: '#6b7280',
|
||||
moderator: '#10b981',
|
||||
admin: '#60a5fa'
|
||||
} as const;
|
||||
|
||||
function resolveNormalizedRolePosition(
|
||||
position: unknown,
|
||||
fallbackPosition: number | undefined,
|
||||
existingRoles: readonly RoomRole[],
|
||||
defaultRoles: readonly RoomRole[]
|
||||
): number {
|
||||
if (typeof position === 'number' && Number.isFinite(position)) {
|
||||
return position;
|
||||
}
|
||||
|
||||
if (typeof fallbackPosition === 'number') {
|
||||
return fallbackPosition;
|
||||
}
|
||||
|
||||
return nextRolePosition(existingRoles.length > 0 ? existingRoles : defaultRoles);
|
||||
}
|
||||
|
||||
function normalizeRoomRoleEntry(
|
||||
role: RoomRole | null | undefined,
|
||||
defaultsById: Map<string, RoomRole>,
|
||||
existingRoles: readonly RoomRole[],
|
||||
defaultRoles: readonly RoomRole[]
|
||||
): RoomRole | null {
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = typeof role.id === 'string' ? role.id.trim() : '';
|
||||
const fallbackRole = defaultsById.get(id);
|
||||
const name = normalizeName(typeof role.name === 'string' ? role.name : (fallbackRole?.name ?? 'Role'));
|
||||
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
position: resolveNormalizedRolePosition(role.position, fallbackRole?.position, existingRoles, defaultRoles),
|
||||
color: typeof role.color === 'string' && role.color.trim() ? role.color.trim() : fallbackRole?.color,
|
||||
isSystem: typeof role.isSystem === 'boolean' ? role.isSystem : fallbackRole?.isSystem,
|
||||
permissions: normalizePermissionMatrix(role.permissions ?? fallbackRole?.permissions)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDefaultRoomRoles(legacyPermissions?: RoomPermissions): RoomRole[] {
|
||||
const everyonePermissions: RoomPermissionMatrix = {
|
||||
joinVoice: legacyPermissions?.allowVoice === false ? 'deny' : 'allow',
|
||||
shareScreen: legacyPermissions?.allowScreenShare === false ? 'deny' : 'allow',
|
||||
uploadFiles: legacyPermissions?.allowFileUploads === false ? 'deny' : 'allow'
|
||||
};
|
||||
const moderatorPermissions: RoomPermissionMatrix = {
|
||||
kickMembers: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
manageChannels: legacyPermissions?.moderatorsManageRooms ? 'allow' : 'inherit',
|
||||
manageIcon: legacyPermissions?.moderatorsManageIcon ? 'allow' : 'inherit'
|
||||
};
|
||||
const adminPermissions: RoomPermissionMatrix = {
|
||||
kickMembers: 'allow',
|
||||
banMembers: 'allow',
|
||||
manageBans: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
manageChannels: legacyPermissions?.adminsManageRooms ? 'allow' : 'inherit',
|
||||
manageIcon: legacyPermissions?.adminsManageIcon ? 'allow' : 'inherit'
|
||||
};
|
||||
|
||||
return [
|
||||
buildSystemRole(SYSTEM_ROLE_IDS.everyone, '@everyone', 0, everyonePermissions, ROLE_COLORS.everyone),
|
||||
buildSystemRole(SYSTEM_ROLE_IDS.moderator, 'Moderator', 200, moderatorPermissions, ROLE_COLORS.moderator),
|
||||
buildSystemRole(SYSTEM_ROLE_IDS.admin, 'Admin', 300, adminPermissions, ROLE_COLORS.admin)
|
||||
];
|
||||
}
|
||||
|
||||
export function sortRolesForDisplay(roles: readonly RoomRole[]): RoomRole[] {
|
||||
return [...roles].sort(roleSortDescending);
|
||||
}
|
||||
|
||||
export function normalizeRoomRoles(roles: readonly RoomRole[] | undefined, legacyPermissions?: RoomPermissions): RoomRole[] {
|
||||
const defaultRoles = buildDefaultRoomRoles(legacyPermissions);
|
||||
const defaultsById = buildRoleLookup(defaultRoles);
|
||||
const normalizedById = new Map<string, RoomRole>();
|
||||
|
||||
for (const role of roles ?? []) {
|
||||
const normalizedRole = normalizeRoomRoleEntry(role, defaultsById, Array.from(normalizedById.values()), defaultRoles);
|
||||
|
||||
if (normalizedRole) {
|
||||
normalizedById.set(normalizedRole.id, normalizedRole);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [roleId, role] of defaultsById) {
|
||||
if (!normalizedById.has(roleId)) {
|
||||
normalizedById.set(roleId, role);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(normalizedById.values()).sort(roleSortAscending);
|
||||
}
|
||||
|
||||
export function getRoomRoleById(roles: readonly RoomRole[] | undefined, roleId: string): RoomRole | undefined {
|
||||
return (roles ?? []).find((role) => role.id === roleId);
|
||||
}
|
||||
|
||||
export function createCustomRoomRole(name: string, roles: readonly RoomRole[]): RoomRole {
|
||||
const normalizedName = normalizeName(name) || 'New Role';
|
||||
|
||||
return {
|
||||
id: `role-${crypto.randomUUID()}`,
|
||||
name: normalizedName,
|
||||
position: nextRolePosition(roles),
|
||||
permissions: {}
|
||||
};
|
||||
}
|
||||
|
||||
export function reorderRoles(roles: readonly RoomRole[], orderedRoleIds: readonly string[]): RoomRole[] {
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
const systemRoles = roles.filter((role) => role.isSystem);
|
||||
const customRoles = orderedRoleIds.map((roleId) => roleLookup.get(roleId)).filter((role): role is RoomRole => !!role && !role.isSystem);
|
||||
const remainingCustomRoles = roles.filter((role) => !role.isSystem && !orderedRoleIds.includes(role.id));
|
||||
const orderedRoles = sortRolesForDisplay(systemRoles).concat(customRoles)
|
||||
.concat(sortRolesForDisplay(remainingCustomRoles));
|
||||
|
||||
return orderedRoles
|
||||
.map((role, index) => ({
|
||||
...role,
|
||||
position: (orderedRoles.length - index - 1) * 100
|
||||
}))
|
||||
.sort(roleSortAscending);
|
||||
}
|
||||
|
||||
export function withUpdatedRole(roles: readonly RoomRole[], roleId: string, updates: Partial<RoomRole>): RoomRole[] {
|
||||
return normalizeRoomRoles(
|
||||
roles.map((role) => {
|
||||
if (role.id !== roleId) {
|
||||
return role;
|
||||
}
|
||||
|
||||
return {
|
||||
...role,
|
||||
...updates,
|
||||
permissions: normalizePermissionMatrix(updates.permissions ?? role.permissions)
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function findAssignableRoles(roles: readonly RoomRole[]): RoomRole[] {
|
||||
return sortRolesForDisplay(roles).filter((role) => role.id !== SYSTEM_ROLE_IDS.everyone);
|
||||
}
|
||||
209
toju-app/src/app/domains/access-control/domain/room.rules.ts
Normal file
209
toju-app/src/app/domains/access-control/domain/room.rules.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
PermissionState,
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomPermissionKey,
|
||||
RoomPermissions,
|
||||
RoomRole,
|
||||
RoomRoleAssignment,
|
||||
UserRole
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import {
|
||||
getRolePermissionState,
|
||||
permissionStateToBoolean,
|
||||
resolveLegacyAllowState
|
||||
} from './access-control.internal';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
import {
|
||||
getAssignedRoleIds,
|
||||
normalizeRoomRoleAssignments,
|
||||
removeRoleFromAssignments
|
||||
} from './role-assignment.rules';
|
||||
import {
|
||||
getRoomRoleById,
|
||||
normalizeRoomRoles,
|
||||
withUpdatedRole
|
||||
} from './role.rules';
|
||||
import { normalizeChannelPermissionOverrides, resolveRoomPermission } from './permission.rules';
|
||||
|
||||
function applyRolePermissionChanges(
|
||||
roles: readonly RoomRole[],
|
||||
role: RoomRole | null | undefined,
|
||||
changes: Partial<Record<RoomPermissionKey, PermissionState | undefined>>
|
||||
): RoomRole[] {
|
||||
if (!role) {
|
||||
return [...roles];
|
||||
}
|
||||
|
||||
return withUpdatedRole(roles, role.id, {
|
||||
permissions: {
|
||||
...role.permissions,
|
||||
...changes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getEveryoneLegacyPermissionChanges(
|
||||
role: RoomRole,
|
||||
permissions: Partial<RoomPermissions>
|
||||
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
|
||||
return {
|
||||
joinVoice: resolveLegacyAllowState(permissions.allowVoice, role.permissions?.joinVoice, 'deny'),
|
||||
shareScreen: resolveLegacyAllowState(permissions.allowScreenShare, role.permissions?.shareScreen, 'deny'),
|
||||
uploadFiles: resolveLegacyAllowState(permissions.allowFileUploads, role.permissions?.uploadFiles, 'deny')
|
||||
};
|
||||
}
|
||||
|
||||
function getModeratorLegacyPermissionChanges(
|
||||
role: RoomRole,
|
||||
permissions: Partial<RoomPermissions>
|
||||
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
|
||||
return {
|
||||
manageChannels: resolveLegacyAllowState(permissions.moderatorsManageRooms, role.permissions?.manageChannels, 'inherit'),
|
||||
manageIcon: resolveLegacyAllowState(permissions.moderatorsManageIcon, role.permissions?.manageIcon, 'inherit')
|
||||
};
|
||||
}
|
||||
|
||||
function getAdminLegacyPermissionChanges(
|
||||
role: RoomRole,
|
||||
permissions: Partial<RoomPermissions>
|
||||
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
|
||||
return {
|
||||
manageChannels: resolveLegacyAllowState(permissions.adminsManageRooms, role.permissions?.manageChannels, 'inherit'),
|
||||
manageIcon: resolveLegacyAllowState(permissions.adminsManageIcon, role.permissions?.manageIcon, 'inherit')
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLegacyRole(room: Room, identity: MemberIdentity | null | undefined): UserRole {
|
||||
if (!identity) {
|
||||
return 'member';
|
||||
}
|
||||
|
||||
if (room.hostId === identity.id || room.hostId === identity.oderId) {
|
||||
return 'host';
|
||||
}
|
||||
|
||||
const assignedRoleIds = getAssignedRoleIds(room.roleAssignments, identity);
|
||||
|
||||
if (assignedRoleIds.includes(SYSTEM_ROLE_IDS.admin)) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if (assignedRoleIds.includes(SYSTEM_ROLE_IDS.moderator)) {
|
||||
return 'moderator';
|
||||
}
|
||||
|
||||
if (
|
||||
resolveRoomPermission(room, identity, 'manageRoles') ||
|
||||
resolveRoomPermission(room, identity, 'banMembers') ||
|
||||
resolveRoomPermission(room, identity, 'manageBans') ||
|
||||
resolveRoomPermission(room, identity, 'manageServer')
|
||||
) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if (
|
||||
resolveRoomPermission(room, identity, 'kickMembers') ||
|
||||
resolveRoomPermission(room, identity, 'deleteMessages') ||
|
||||
resolveRoomPermission(room, identity, 'manageChannels') ||
|
||||
resolveRoomPermission(room, identity, 'manageIcon')
|
||||
) {
|
||||
return 'moderator';
|
||||
}
|
||||
|
||||
return 'member';
|
||||
}
|
||||
|
||||
export function deriveLegacyRoomPermissions(room: Pick<Room, 'roles' | 'permissions' | 'slowModeInterval'>): RoomPermissions {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const everyoneRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.everyone);
|
||||
const moderatorRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.moderator);
|
||||
const adminRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.admin);
|
||||
|
||||
return {
|
||||
allowVoice: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'joinVoice'), true),
|
||||
allowScreenShare: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'shareScreen'), true),
|
||||
allowFileUploads: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'uploadFiles'), true),
|
||||
adminsManageRooms: getRolePermissionState(adminRole, 'manageChannels') === 'allow',
|
||||
moderatorsManageRooms: getRolePermissionState(moderatorRole, 'manageChannels') === 'allow',
|
||||
adminsManageIcon: getRolePermissionState(adminRole, 'manageIcon') === 'allow',
|
||||
moderatorsManageIcon: getRolePermissionState(moderatorRole, 'manageIcon') === 'allow',
|
||||
slowModeInterval: room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export function withLegacyRoomPermissions(room: Room, permissions: Partial<RoomPermissions>): Room {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const everyoneRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.everyone);
|
||||
const moderatorRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.moderator);
|
||||
const adminRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.admin);
|
||||
|
||||
let nextRoles = applyRolePermissionChanges(roles, everyoneRole, everyoneRole ? getEveryoneLegacyPermissionChanges(everyoneRole, permissions) : {});
|
||||
|
||||
nextRoles = applyRolePermissionChanges(
|
||||
nextRoles,
|
||||
moderatorRole,
|
||||
moderatorRole ? getModeratorLegacyPermissionChanges(moderatorRole, permissions) : {}
|
||||
);
|
||||
|
||||
nextRoles = applyRolePermissionChanges(nextRoles, adminRole, adminRole ? getAdminLegacyPermissionChanges(adminRole, permissions) : {});
|
||||
|
||||
return normalizeRoomAccessControl({
|
||||
...room,
|
||||
roles: nextRoles,
|
||||
slowModeInterval: permissions.slowModeInterval ?? room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
export function hydrateRoomMembers(room: Room): RoomMember[] {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleAssignments = normalizeRoomRoleAssignments(room.roleAssignments, room.members, roles);
|
||||
|
||||
return (room.members ?? []).map((member) => {
|
||||
const roleIds = getAssignedRoleIds(roleAssignments, member);
|
||||
const hydratedRoom: Room = {
|
||||
...room,
|
||||
roles,
|
||||
roleAssignments
|
||||
};
|
||||
|
||||
return {
|
||||
...member,
|
||||
roleIds,
|
||||
role: resolveLegacyRole(hydratedRoom, member)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRoomAccessControl(room: Room): Room {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleAssignments = normalizeRoomRoleAssignments(room.roleAssignments, room.members, roles);
|
||||
const channelPermissions = normalizeChannelPermissionOverrides(room.channelPermissions, roles);
|
||||
const slowModeInterval = room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0;
|
||||
const nextRoom: Room = {
|
||||
...room,
|
||||
roles,
|
||||
roleAssignments,
|
||||
channelPermissions,
|
||||
slowModeInterval
|
||||
};
|
||||
|
||||
nextRoom.permissions = deriveLegacyRoomPermissions(nextRoom);
|
||||
nextRoom.members = hydrateRoomMembers(nextRoom);
|
||||
|
||||
return nextRoom;
|
||||
}
|
||||
|
||||
export function removeRole(
|
||||
roles: readonly RoomRole[],
|
||||
assignments: readonly RoomRoleAssignment[] | undefined,
|
||||
roleId: string
|
||||
): { roles: RoomRole[]; roleAssignments: RoomRoleAssignment[] } {
|
||||
const nextRoles = roles.filter((role) => role.id !== roleId || role.isSystem);
|
||||
|
||||
return {
|
||||
roles: normalizeRoomRoles(nextRoles),
|
||||
roleAssignments: removeRoleFromAssignments(assignments, roleId)
|
||||
};
|
||||
}
|
||||
6
toju-app/src/app/domains/access-control/index.ts
Normal file
6
toju-app/src/app/domains/access-control/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './domain/access-control.models';
|
||||
export * from './domain/access-control.constants';
|
||||
export * from './domain/role.rules';
|
||||
export * from './domain/role-assignment.rules';
|
||||
export * from './domain/permission.rules';
|
||||
export * from './domain/room.rules';
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/prefer-ngsrc -->
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||
<div #composerRoot>
|
||||
@if (replyTo()) {
|
||||
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
|
||||
|
||||
@@ -77,43 +77,17 @@
|
||||
@if (msg.isDeleted) {
|
||||
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
|
||||
} @else {
|
||||
<div class="chat-markdown mt-1 break-words">
|
||||
<remark
|
||||
[markdown]="msg.content"
|
||||
[processor]="$any(remarkProcessor)"
|
||||
>
|
||||
<ng-template
|
||||
[remarkTemplate]="'code'"
|
||||
let-node
|
||||
>
|
||||
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
|
||||
<remark-mermaid [code]="getMermaidCode(node.value)" />
|
||||
} @else {
|
||||
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template
|
||||
[remarkTemplate]="'image'"
|
||||
let-node
|
||||
>
|
||||
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||
<img
|
||||
[appChatImageProxyFallback]="node.url"
|
||||
[alt]="node.alt || 'Shared image'"
|
||||
class="block max-h-80 max-w-full w-auto"
|
||||
loading="lazy"
|
||||
/>
|
||||
@if (isKlipyMediaUrl(node.url)) {
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</remark>
|
||||
</div>
|
||||
@if (requiresRichMarkdown(msg.content)) {
|
||||
@defer {
|
||||
<div class="chat-markdown mt-1 break-words">
|
||||
<app-chat-message-markdown [content]="msg.content" />
|
||||
</div>
|
||||
} @placeholder {
|
||||
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
||||
}
|
||||
|
||||
@if (attachmentsList.length > 0) {
|
||||
<div class="mt-2 space-y-2">
|
||||
|
||||
@@ -24,11 +24,6 @@ import {
|
||||
lucideTrash2,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { MermaidComponent, RemarkModule } from 'ngx-remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentFacade,
|
||||
@@ -41,7 +36,7 @@ import {
|
||||
ChatVideoPlayerComponent,
|
||||
UserAvatarComponent
|
||||
} from '../../../../../../shared';
|
||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
|
||||
import {
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
@@ -60,28 +55,7 @@ const COMMON_EMOJIS = [
|
||||
'🔥',
|
||||
'👀'
|
||||
];
|
||||
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
||||
cs: 'csharp',
|
||||
html: 'markup',
|
||||
js: 'javascript',
|
||||
md: 'markdown',
|
||||
plain: 'none',
|
||||
plaintext: 'none',
|
||||
py: 'python',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
svg: 'markup',
|
||||
text: 'none',
|
||||
ts: 'typescript',
|
||||
xml: 'markup',
|
||||
yml: 'yaml',
|
||||
zsh: 'bash'
|
||||
};
|
||||
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
|
||||
const REMARK_PROCESSOR = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkBreaks);
|
||||
const RICH_MARKDOWN_PATTERN = /(^|\n)(#{1,6}\s|>\s|[-*+]\s|\d+\.\s|```|~~~)|!\[[^\]]*\]\([^)]+\)|\[[^\]]+\]\([^)]+\)|`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|_[^_\n]+_|(?:^|\n)\|.+\|/m;
|
||||
|
||||
interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
isAudio: boolean;
|
||||
@@ -101,9 +75,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
NgIcon,
|
||||
ChatAudioPlayerComponent,
|
||||
ChatVideoPlayerComponent,
|
||||
RemarkModule,
|
||||
MermaidComponent,
|
||||
ChatImageProxyFallbackDirective,
|
||||
ChatMessageMarkdownComponent,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -136,7 +108,6 @@ export class ChatMessageItemComponent {
|
||||
readonly repliedMessage = input<Message | undefined>();
|
||||
readonly currentUserId = input<string | null>(null);
|
||||
readonly isAdmin = input(false);
|
||||
readonly remarkProcessor = REMARK_PROCESSOR;
|
||||
|
||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||
@@ -320,23 +291,8 @@ export class ChatMessageItemComponent {
|
||||
);
|
||||
}
|
||||
|
||||
getMermaidCode(code?: string): string {
|
||||
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
|
||||
}
|
||||
|
||||
isKlipyMediaUrl(url?: string): boolean {
|
||||
if (!url)
|
||||
return false;
|
||||
|
||||
return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url);
|
||||
}
|
||||
|
||||
isMermaidCodeBlock(lang?: string): boolean {
|
||||
return this.normalizeCodeLanguage(lang) === 'mermaid';
|
||||
}
|
||||
|
||||
getCodeBlockClass(lang?: string): string {
|
||||
return `language-${this.normalizeCodeLanguage(lang)}`;
|
||||
requiresRichMarkdown(content: string): boolean {
|
||||
return RICH_MARKDOWN_PATTERN.test(content);
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
@@ -468,15 +424,6 @@ export class ChatMessageItemComponent {
|
||||
this.downloadRequested.emit(attachment);
|
||||
}
|
||||
|
||||
private normalizeCodeLanguage(lang?: string): string {
|
||||
const normalized = (lang || '').trim().toLowerCase();
|
||||
|
||||
if (!normalized)
|
||||
return 'none';
|
||||
|
||||
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
||||
const isVideo = this.isVideoAttachment(attachment);
|
||||
const isAudio = this.isAudioAttachment(attachment);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<remark
|
||||
[markdown]="content()"
|
||||
[processor]="$any(remarkProcessor)"
|
||||
>
|
||||
<ng-template
|
||||
[remarkTemplate]="'code'"
|
||||
let-node
|
||||
>
|
||||
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
|
||||
<remark-mermaid [code]="getMermaidCode(node.value)" />
|
||||
} @else {
|
||||
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template
|
||||
[remarkTemplate]="'image'"
|
||||
let-node
|
||||
>
|
||||
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||
<img
|
||||
[appChatImageProxyFallback]="node.url"
|
||||
[alt]="node.alt || 'Shared image'"
|
||||
class="block max-h-80 max-w-full w-auto"
|
||||
loading="lazy"
|
||||
/>
|
||||
@if (isKlipyMediaUrl(node.url)) {
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</remark>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, input } from '@angular/core';
|
||||
import { MermaidComponent, RemarkModule } from 'ngx-remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||
|
||||
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
||||
cs: 'csharp',
|
||||
html: 'markup',
|
||||
js: 'javascript',
|
||||
md: 'markdown',
|
||||
plain: 'none',
|
||||
plaintext: 'none',
|
||||
py: 'python',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
svg: 'markup',
|
||||
text: 'none',
|
||||
ts: 'typescript',
|
||||
xml: 'markup',
|
||||
yml: 'yaml',
|
||||
zsh: 'bash'
|
||||
};
|
||||
const KLIPY_MEDIA_URL_PATTERN = /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i;
|
||||
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
|
||||
const REMARK_PROCESSOR = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkBreaks);
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-message-markdown',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RemarkModule,
|
||||
MermaidComponent,
|
||||
ChatImageProxyFallbackDirective
|
||||
],
|
||||
templateUrl: './chat-message-markdown.component.html'
|
||||
})
|
||||
export class ChatMessageMarkdownComponent {
|
||||
readonly content = input.required<string>();
|
||||
readonly remarkProcessor = REMARK_PROCESSOR;
|
||||
|
||||
getMermaidCode(code?: string): string {
|
||||
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
|
||||
}
|
||||
|
||||
isKlipyMediaUrl(url?: string): boolean {
|
||||
if (!url)
|
||||
return false;
|
||||
|
||||
return KLIPY_MEDIA_URL_PATTERN.test(url);
|
||||
}
|
||||
|
||||
isMermaidCodeBlock(lang?: string): boolean {
|
||||
return this.normalizeCodeLanguage(lang) === 'mermaid';
|
||||
}
|
||||
|
||||
getCodeBlockClass(lang?: string): string {
|
||||
return `language-${this.normalizeCodeLanguage(lang)}`;
|
||||
}
|
||||
|
||||
private normalizeCodeLanguage(lang?: string): string {
|
||||
const normalized = (lang || '').trim().toLowerCase();
|
||||
|
||||
if (!normalized)
|
||||
return 'none';
|
||||
|
||||
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
|
||||
readonly dateSeparatorLabels = computed(() => {
|
||||
const labels = new Map<number, string>();
|
||||
|
||||
let previousDayKey: string | null = null;
|
||||
|
||||
this.messages().forEach((message, index) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
||||
<div
|
||||
class="flex h-[min(70vh,42rem)] w-full flex-col overflow-hidden rounded-[1.65rem] border border-border/80 shadow-2xl ring-1 ring-white/5"
|
||||
role="dialog"
|
||||
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import {
|
||||
selectActiveChannelId,
|
||||
selectCurrentRoom
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
merge,
|
||||
interval,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -16,10 +20,7 @@ import { NotificationsFacade } from '../../application/notifications.facade';
|
||||
@Component({
|
||||
selector: 'app-notifications-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon
|
||||
],
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideBell,
|
||||
@@ -49,10 +50,12 @@ export class NotificationsSettingsComponent {
|
||||
|
||||
return channels.length > 0
|
||||
? channels
|
||||
: [{ id: 'general',
|
||||
name: 'general',
|
||||
type: 'text',
|
||||
position: 0 }];
|
||||
: [
|
||||
{ id: 'general',
|
||||
name: 'general',
|
||||
type: 'text',
|
||||
position: 0 }
|
||||
];
|
||||
}
|
||||
|
||||
onNotificationsEnabledChange(event: Event): void {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Channel } from '../../../shared-kernel';
|
||||
import type {
|
||||
Channel,
|
||||
ChannelPermissionOverride,
|
||||
RoomRole,
|
||||
RoomRoleAssignment
|
||||
} from '../../../shared-kernel';
|
||||
|
||||
export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
|
||||
|
||||
@@ -17,6 +22,10 @@ export interface ServerInfo {
|
||||
isPrivate: boolean;
|
||||
tags?: string[];
|
||||
channels?: Channel[];
|
||||
slowModeInterval?: number;
|
||||
roles?: RoomRole[];
|
||||
roleAssignments?: RoomRoleAssignment[];
|
||||
channelPermissions?: ChannelPermissionOverride[];
|
||||
createdAt: number;
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import {
|
||||
ChannelPermissionOverride,
|
||||
type Channel,
|
||||
ROOM_PERMISSION_KEYS,
|
||||
RoomRole,
|
||||
RoomRoleAssignment,
|
||||
User
|
||||
} from '../../../shared-kernel';
|
||||
import { ServerEndpointStateService } from '../application/server-endpoint-state.service';
|
||||
@@ -48,10 +52,12 @@ export class ServerDirectoryApiService {
|
||||
return this.endpointState.findServerByUrl(selector.sourceUrl) ?? null;
|
||||
}
|
||||
|
||||
return this.endpointState.activeServer()
|
||||
?? this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible')
|
||||
?? this.endpointState.servers()[0]
|
||||
?? null;
|
||||
return (
|
||||
this.endpointState.activeServer() ??
|
||||
this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible') ??
|
||||
this.endpointState.servers()[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
searchServers(query: string, shouldSearchAllServers: boolean): Observable<ServerInfo[]> {
|
||||
@@ -67,41 +73,35 @@ export class ServerDirectoryApiService {
|
||||
return this.getAllServersFromAllEndpoints();
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`).pipe(
|
||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
|
||||
return this.http
|
||||
.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
|
||||
.pipe(
|
||||
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server:', error);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
return this.http.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
|
||||
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server:', error);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
registerServer(
|
||||
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.post<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers`, server)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to register server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
return this.http.post<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers`, server).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to register server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateServer(
|
||||
@@ -113,157 +113,111 @@ export class ServerDirectoryApiService {
|
||||
},
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.put<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
return this.http.put<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http
|
||||
.delete<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unregister server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
return this.http.delete<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unregister server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
|
||||
return this.http
|
||||
.get<User[]>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server users:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
return this.http.get<User[]>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server users:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
requestJoin(
|
||||
request: ServerJoinAccessRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerJoinAccessResponse> {
|
||||
return this.http
|
||||
.post<ServerJoinAccessResponse>(
|
||||
`${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`,
|
||||
request
|
||||
)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send join request:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
requestJoin(request: ServerJoinAccessRequest, selector?: ServerSourceSelector): Observable<ServerJoinAccessResponse> {
|
||||
return this.http.post<ServerJoinAccessResponse>(`${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`, request).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send join request:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
createInvite(
|
||||
serverId: string,
|
||||
request: CreateServerInviteRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInviteInfo> {
|
||||
return this.http
|
||||
.post<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to create invite:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
createInvite(serverId: string, request: CreateServerInviteRequest, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
|
||||
return this.http.post<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to create invite:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
|
||||
return this.http
|
||||
.get<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get invite:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
return this.http.get<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get invite:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
kickServerMember(
|
||||
serverId: string,
|
||||
request: KickServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to kick server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
kickServerMember(serverId: string, request: KickServerMemberRequest, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to kick server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
banServerMember(
|
||||
serverId: string,
|
||||
request: BanServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to ban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
banServerMember(serverId: string, request: BanServerMemberRequest, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to ban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unbanServerMember(
|
||||
serverId: string,
|
||||
request: UnbanServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
unbanServerMember(serverId: string, request: UnbanServerMemberRequest, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId })
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to notify leave:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
return this.http.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId }).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to notify leave:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateUserCount(serverId: string, count: number): Observable<void> {
|
||||
return this.http
|
||||
.patch<void>(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count })
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update user count:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
return this.http.patch<void>(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count }).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update user count:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
sendHeartbeat(serverId: string): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {})
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send heartbeat:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
return this.http.post<void>(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {}).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send heartbeat:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
|
||||
@@ -274,71 +228,51 @@ export class ServerDirectoryApiService {
|
||||
return this.resolveEndpoint(selector)?.url ?? this.endpointState.getPrimaryDefaultServerUrl();
|
||||
}
|
||||
|
||||
private unwrapServersResponse(
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[]
|
||||
): ServerInfo[] {
|
||||
return Array.isArray(response)
|
||||
? response
|
||||
: (response.servers ?? []);
|
||||
private unwrapServersResponse(response: { servers: ServerInfo[]; total: number } | ServerInfo[]): ServerInfo[] {
|
||||
return Array.isArray(response) ? response : (response.servers ?? []);
|
||||
}
|
||||
|
||||
private searchSingleEndpoint(
|
||||
query: string,
|
||||
apiBaseUrl: string,
|
||||
source?: ServerEndpoint | null
|
||||
): Observable<ServerInfo[]> {
|
||||
private searchSingleEndpoint(query: string, apiBaseUrl: string, source?: ServerEndpoint | null): Observable<ServerInfo[]> {
|
||||
const params = new HttpParams().set('q', query);
|
||||
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, source)),
|
||||
catchError((error) => {
|
||||
console.error('Failed to search servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params }).pipe(
|
||||
map((response) => this.normalizeServerList(response, source)),
|
||||
catchError((error) => {
|
||||
console.error('Failed to search servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this.endpointState.activeServers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline'
|
||||
);
|
||||
const onlineEndpoints = this.endpointState.activeServers().filter((endpoint) => endpoint.status !== 'offline');
|
||||
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
|
||||
}
|
||||
|
||||
return forkJoin(
|
||||
onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))
|
||||
).pipe(
|
||||
return forkJoin(onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))).pipe(
|
||||
map((resultArrays) => resultArrays.flat()),
|
||||
map((servers) => this.deduplicateById(servers))
|
||||
);
|
||||
}
|
||||
|
||||
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this.endpointState.activeServers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline'
|
||||
);
|
||||
const onlineEndpoints = this.endpointState.activeServers().filter((endpoint) => endpoint.status !== 'offline');
|
||||
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
||||
catchError(() => of([]))
|
||||
);
|
||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`).pipe(
|
||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
||||
catchError(() => of([]))
|
||||
);
|
||||
}
|
||||
|
||||
return forkJoin(
|
||||
onlineEndpoints.map((endpoint) =>
|
||||
this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, endpoint)),
|
||||
catchError(() => of([] as ServerInfo[]))
|
||||
)
|
||||
this.http.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`).pipe(
|
||||
map((response) => this.normalizeServerList(response, endpoint)),
|
||||
catchError(() => of([] as ServerInfo[]))
|
||||
)
|
||||
)
|
||||
).pipe(map((resultArrays) => resultArrays.flat()));
|
||||
}
|
||||
@@ -356,17 +290,11 @@ export class ServerDirectoryApiService {
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeServerList(
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[],
|
||||
source?: ServerEndpoint | null
|
||||
): ServerInfo[] {
|
||||
private normalizeServerList(response: { servers: ServerInfo[]; total: number } | ServerInfo[], source?: ServerEndpoint | null): ServerInfo[] {
|
||||
return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source));
|
||||
}
|
||||
|
||||
private normalizeServerInfo(
|
||||
server: ServerInfo | Record<string, unknown>,
|
||||
source?: ServerEndpoint | null
|
||||
): ServerInfo {
|
||||
private normalizeServerInfo(server: ServerInfo | Record<string, unknown>, source?: ServerEndpoint | null): ServerInfo {
|
||||
const candidate = server as Record<string, unknown>;
|
||||
const sourceName = this.getStringValue(candidate['sourceName']);
|
||||
const sourceUrl = this.getStringValue(candidate['sourceUrl']);
|
||||
@@ -384,14 +312,16 @@ export class ServerDirectoryApiService {
|
||||
maxUsers: this.getNumberValue(candidate['maxUsers']),
|
||||
hasPassword: this.getBooleanValue(candidate['hasPassword']),
|
||||
isPrivate: this.getBooleanValue(candidate['isPrivate']),
|
||||
tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [],
|
||||
tags: Array.isArray(candidate['tags']) ? (candidate['tags'] as string[]) : [],
|
||||
channels: this.getChannelsValue(candidate['channels']),
|
||||
slowModeInterval: this.getNumberValue(candidate['slowModeInterval'], 0),
|
||||
roles: this.getRolesValue(candidate['roles']),
|
||||
roleAssignments: this.getRoleAssignmentsValue(candidate['roleAssignments']),
|
||||
channelPermissions: this.getChannelPermissionOverridesValue(candidate['channelPermissions']),
|
||||
createdAt: this.getNumberValue(candidate['createdAt'], Date.now()),
|
||||
sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
|
||||
sourceName: sourceName ?? source?.name,
|
||||
sourceUrl: sourceUrl
|
||||
? this.endpointState.sanitiseUrl(sourceUrl)
|
||||
: (source ? this.endpointState.sanitiseUrl(source.url) : undefined)
|
||||
sourceUrl: sourceUrl ? this.endpointState.sanitiseUrl(sourceUrl) : source ? this.endpointState.sanitiseUrl(source.url) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -430,6 +360,123 @@ export class ServerDirectoryApiService {
|
||||
.filter((channel): channel is Channel => !!channel);
|
||||
}
|
||||
|
||||
private getRolesValue(value: unknown): RoomRole[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((role): role is Record<string, unknown> => !!role && typeof role === 'object')
|
||||
.flatMap((role, index) => {
|
||||
const id = this.getStringValue(role['id']);
|
||||
const name = this.getStringValue(role['name']);
|
||||
const position = this.getNumberValue(role['position'], index * 100);
|
||||
|
||||
if (!id || !name) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedRole: RoomRole = {
|
||||
id,
|
||||
name,
|
||||
position,
|
||||
permissions: this.getPermissionMatrixValue(role['permissions'])
|
||||
};
|
||||
const color = this.getStringValue(role['color']);
|
||||
const isSystem = typeof role['isSystem'] === 'boolean' ? role['isSystem'] : this.getBooleanValue(role['isSystem']);
|
||||
|
||||
if (color) {
|
||||
normalizedRole.color = color;
|
||||
}
|
||||
|
||||
if (typeof isSystem === 'boolean') {
|
||||
normalizedRole.isSystem = isSystem;
|
||||
}
|
||||
|
||||
return [normalizedRole];
|
||||
});
|
||||
}
|
||||
|
||||
private getRoleAssignmentsValue(value: unknown): RoomRoleAssignment[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((assignment): assignment is Record<string, unknown> => !!assignment && typeof assignment === 'object')
|
||||
.flatMap((assignment) => {
|
||||
const userId = this.getStringValue(assignment['userId']);
|
||||
const roleIds = Array.isArray(assignment['roleIds'])
|
||||
? assignment['roleIds'].filter((roleId): roleId is string => typeof roleId === 'string')
|
||||
: [];
|
||||
|
||||
if (!userId || roleIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedAssignment: RoomRoleAssignment = {
|
||||
userId,
|
||||
roleIds
|
||||
};
|
||||
const oderId = this.getStringValue(assignment['oderId']);
|
||||
|
||||
if (oderId) {
|
||||
normalizedAssignment.oderId = oderId;
|
||||
}
|
||||
|
||||
return [normalizedAssignment];
|
||||
});
|
||||
}
|
||||
|
||||
private getChannelPermissionOverridesValue(value: unknown): ChannelPermissionOverride[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((override): override is Record<string, unknown> => !!override && typeof override === 'object')
|
||||
.map((override) => {
|
||||
const channelId = this.getStringValue(override['channelId']);
|
||||
const targetId = this.getStringValue(override['targetId']);
|
||||
const targetType = override['targetType'] === 'role' || override['targetType'] === 'user' ? override['targetType'] : undefined;
|
||||
const permission = ROOM_PERMISSION_KEYS.find((candidatePermission) => candidatePermission === override['permission']);
|
||||
const valueState =
|
||||
override['value'] === 'allow' || override['value'] === 'deny' || override['value'] === 'inherit' ? override['value'] : undefined;
|
||||
|
||||
if (!channelId || !targetId || !targetType || !permission || !valueState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
channelId,
|
||||
targetId,
|
||||
targetType,
|
||||
permission,
|
||||
value: valueState
|
||||
} satisfies ChannelPermissionOverride;
|
||||
})
|
||||
.filter((override): override is ChannelPermissionOverride => !!override);
|
||||
}
|
||||
|
||||
private getPermissionMatrixValue(value: unknown): RoomRole['permissions'] {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matrix = value as Record<string, unknown>;
|
||||
const normalized = ROOM_PERMISSION_KEYS.reduce<NonNullable<RoomRole['permissions']>>((nextMatrix, permission) => {
|
||||
const permissionValue = matrix[permission];
|
||||
|
||||
if (permissionValue === 'allow' || permissionValue === 'deny' || permissionValue === 'inherit') {
|
||||
nextMatrix[permission] = permissionValue;
|
||||
}
|
||||
|
||||
return nextMatrix;
|
||||
}, {});
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
private getChannelTypeValue(value: unknown): Channel['type'] | undefined {
|
||||
return value === 'text' || value === 'voice' ? value : undefined;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
SettingsModalService,
|
||||
type SettingsPage
|
||||
} from '../../../core/services/settings-modal.service';
|
||||
Injectable,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
|
||||
import { SettingsModalService, type SettingsPage } from '../../../core/services/settings-modal.service';
|
||||
import { ThemeRegistryService } from './theme-registry.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -13,7 +14,7 @@ export class ElementPickerService {
|
||||
private readonly modal = inject(SettingsModalService);
|
||||
private readonly registry = inject(ThemeRegistryService);
|
||||
|
||||
private removeListeners: Array<() => void> = [];
|
||||
private removeListeners: (() => void)[] = [];
|
||||
private resumePage: SettingsPage | null = null;
|
||||
private shouldRestoreModalOnCancel = true;
|
||||
|
||||
@@ -69,7 +70,6 @@ export class ElementPickerService {
|
||||
|
||||
this.hoveredKey.set(key);
|
||||
};
|
||||
|
||||
const onClick = (event: Event) => {
|
||||
const key = this.resolveThemeKeyFromTarget(event.target);
|
||||
|
||||
@@ -86,7 +86,6 @@ export class ElementPickerService {
|
||||
|
||||
this.completePick(key);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: Event) => {
|
||||
const keyboardEvent = event as KeyboardEvent;
|
||||
|
||||
@@ -129,4 +128,4 @@ export class ElementPickerService {
|
||||
? key
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
ThemeContainerKey,
|
||||
@@ -55,4 +59,4 @@ export class LayoutSyncService {
|
||||
}
|
||||
}, true, `${containerKey} restored to its default layout.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import type { SavedThemeSummary } from '../domain/theme.models';
|
||||
import { ThemeLibraryStorageService } from '../infrastructure/theme-library.storage';
|
||||
import { ThemeService } from './theme.service';
|
||||
@@ -173,4 +178,4 @@ export class ThemeLibraryService {
|
||||
|
||||
this.selectedFileName.set(entries[0]?.fileName ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
ThemeLayoutContainerDefinition,
|
||||
ThemeRegistryEntry
|
||||
} from '../domain/theme.models';
|
||||
import { ThemeLayoutContainerDefinition, ThemeRegistryEntry } from '../domain/theme.models';
|
||||
import {
|
||||
THEME_LAYOUT_CONTAINERS,
|
||||
THEME_REGISTRY,
|
||||
@@ -85,4 +82,4 @@ export class ThemeRegistryService {
|
||||
|
||||
this.mountedCounts.set(nextCounts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
ThemeAnimationDefinition,
|
||||
@@ -13,12 +18,8 @@ import {
|
||||
createDefaultThemeDocument,
|
||||
isLegacyDefaultThemeDocument
|
||||
} from '../domain/theme.defaults';
|
||||
import {
|
||||
createAnimationStarterDefinition
|
||||
} from '../domain/theme.schema';
|
||||
import {
|
||||
findThemeLayoutContainer
|
||||
} from '../domain/theme.registry';
|
||||
import { createAnimationStarterDefinition } from '../domain/theme.schema';
|
||||
import { findThemeLayoutContainer } from '../domain/theme.registry';
|
||||
import { validateThemeDocument } from '../domain/theme.validation';
|
||||
import {
|
||||
loadThemeStorageSnapshot,
|
||||
@@ -280,28 +281,71 @@ export class ThemeService {
|
||||
styles['backgroundImage'] = backgroundLayers.join(', ');
|
||||
}
|
||||
|
||||
if (elementTheme.width) styles['width'] = elementTheme.width;
|
||||
if (elementTheme.height) styles['height'] = elementTheme.height;
|
||||
if (elementTheme.minWidth) styles['minWidth'] = elementTheme.minWidth;
|
||||
if (elementTheme.minHeight) styles['minHeight'] = elementTheme.minHeight;
|
||||
if (elementTheme.maxWidth) styles['maxWidth'] = elementTheme.maxWidth;
|
||||
if (elementTheme.maxHeight) styles['maxHeight'] = elementTheme.maxHeight;
|
||||
if (elementTheme.position) styles['position'] = elementTheme.position;
|
||||
if (elementTheme.top) styles['top'] = elementTheme.top;
|
||||
if (elementTheme.right) styles['right'] = elementTheme.right;
|
||||
if (elementTheme.bottom) styles['bottom'] = elementTheme.bottom;
|
||||
if (elementTheme.left) styles['left'] = elementTheme.left;
|
||||
if (elementTheme.padding) styles['padding'] = elementTheme.padding;
|
||||
if (elementTheme.margin) styles['margin'] = elementTheme.margin;
|
||||
if (elementTheme.border) styles['border'] = elementTheme.border;
|
||||
if (elementTheme.borderRadius) styles['borderRadius'] = elementTheme.borderRadius;
|
||||
if (elementTheme.backgroundColor) styles['backgroundColor'] = elementTheme.backgroundColor;
|
||||
if (elementTheme.color) styles['color'] = elementTheme.color;
|
||||
if (elementTheme.backgroundSize) styles['backgroundSize'] = elementTheme.backgroundSize;
|
||||
if (elementTheme.backgroundPosition) styles['backgroundPosition'] = elementTheme.backgroundPosition;
|
||||
if (elementTheme.backgroundRepeat) styles['backgroundRepeat'] = elementTheme.backgroundRepeat;
|
||||
if (elementTheme.boxShadow) styles['boxShadow'] = elementTheme.boxShadow;
|
||||
if (elementTheme.backdropFilter) styles['backdropFilter'] = elementTheme.backdropFilter;
|
||||
if (elementTheme.width)
|
||||
styles['width'] = elementTheme.width;
|
||||
|
||||
if (elementTheme.height)
|
||||
styles['height'] = elementTheme.height;
|
||||
|
||||
if (elementTheme.minWidth)
|
||||
styles['minWidth'] = elementTheme.minWidth;
|
||||
|
||||
if (elementTheme.minHeight)
|
||||
styles['minHeight'] = elementTheme.minHeight;
|
||||
|
||||
if (elementTheme.maxWidth)
|
||||
styles['maxWidth'] = elementTheme.maxWidth;
|
||||
|
||||
if (elementTheme.maxHeight)
|
||||
styles['maxHeight'] = elementTheme.maxHeight;
|
||||
|
||||
if (elementTheme.position)
|
||||
styles['position'] = elementTheme.position;
|
||||
|
||||
if (elementTheme.top)
|
||||
styles['top'] = elementTheme.top;
|
||||
|
||||
if (elementTheme.right)
|
||||
styles['right'] = elementTheme.right;
|
||||
|
||||
if (elementTheme.bottom)
|
||||
styles['bottom'] = elementTheme.bottom;
|
||||
|
||||
if (elementTheme.left)
|
||||
styles['left'] = elementTheme.left;
|
||||
|
||||
if (elementTheme.padding)
|
||||
styles['padding'] = elementTheme.padding;
|
||||
|
||||
if (elementTheme.margin)
|
||||
styles['margin'] = elementTheme.margin;
|
||||
|
||||
if (elementTheme.border)
|
||||
styles['border'] = elementTheme.border;
|
||||
|
||||
if (elementTheme.borderRadius)
|
||||
styles['borderRadius'] = elementTheme.borderRadius;
|
||||
|
||||
if (elementTheme.backgroundColor)
|
||||
styles['backgroundColor'] = elementTheme.backgroundColor;
|
||||
|
||||
if (elementTheme.color)
|
||||
styles['color'] = elementTheme.color;
|
||||
|
||||
if (elementTheme.backgroundSize)
|
||||
styles['backgroundSize'] = elementTheme.backgroundSize;
|
||||
|
||||
if (elementTheme.backgroundPosition)
|
||||
styles['backgroundPosition'] = elementTheme.backgroundPosition;
|
||||
|
||||
if (elementTheme.backgroundRepeat)
|
||||
styles['backgroundRepeat'] = elementTheme.backgroundRepeat;
|
||||
|
||||
if (elementTheme.boxShadow)
|
||||
styles['boxShadow'] = elementTheme.boxShadow;
|
||||
|
||||
if (elementTheme.backdropFilter)
|
||||
styles['backdropFilter'] = elementTheme.backdropFilter;
|
||||
|
||||
if (typeof elementTheme.opacity === 'number') {
|
||||
styles['opacity'] = `${elementTheme.opacity}`;
|
||||
@@ -512,4 +556,4 @@ export class ThemeService {
|
||||
this.statusTimeoutId = null;
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { DEFAULT_THEME_DOCUMENT } from './theme.defaults';
|
||||
import {
|
||||
THEME_LAYOUT_CONTAINERS,
|
||||
THEME_REGISTRY
|
||||
} from './theme.registry';
|
||||
import { THEME_LAYOUT_CONTAINERS, THEME_REGISTRY } from './theme.registry';
|
||||
import {
|
||||
THEME_ANIMATION_FIELDS,
|
||||
THEME_ELEMENT_STYLE_FIELDS,
|
||||
@@ -168,4 +165,4 @@ export const THEME_LLM_GUIDE = [
|
||||
'- Keep layout edits plausible for the declared container grid size.',
|
||||
'- If a field is unsupported, omit it instead of guessing.',
|
||||
'- If a section does not need changes, leave it empty rather than filling it with noise.'
|
||||
].join('\n');
|
||||
].join('\n');
|
||||
|
||||
@@ -27,6 +27,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
w: 1,
|
||||
h: 1 }
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -40,6 +41,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
w: (appShell?.columns ?? 20) - 1,
|
||||
h: 1 }
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -51,6 +53,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
w: 4,
|
||||
h: 12 }
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -62,6 +65,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
w: 12,
|
||||
h: 12 }
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -87,16 +91,19 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
color: 'hsl(var(--foreground))',
|
||||
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.18), transparent 34%), linear-gradient(180deg, rgba(5, 8, 15, 0.98), rgba(9, 12, 20, 1))'
|
||||
};
|
||||
|
||||
elements['serversRail'] = {
|
||||
backgroundColor: 'hsl(var(--rail-background) / 0.96)',
|
||||
gradient: 'linear-gradient(180deg, rgba(10, 14, 25, 0.92), rgba(6, 9, 16, 0.98))',
|
||||
boxShadow: 'inset -1px 0 0 hsl(var(--border) / 0.82), 18px 0 38px rgba(0, 0, 0, 0.22)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['appWorkspace'] = {
|
||||
backgroundColor: 'hsl(var(--workspace-background))',
|
||||
gradient: 'radial-gradient(circle at top right, hsl(var(--surface-highlight-alt) / 0.14), transparent 30%), linear-gradient(180deg, rgba(9, 12, 21, 0.96), rgba(7, 10, 18, 1))'
|
||||
};
|
||||
|
||||
elements['titleBar'] = {
|
||||
backgroundColor: 'hsl(var(--title-bar-background) / 0.82)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
@@ -104,6 +111,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
boxShadow: 'inset 0 -1px 0 hsl(var(--border) / 0.78), 0 12px 28px rgba(0, 0, 0, 0.18)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['chatRoomChannelsPanel'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background) / 0.9)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
@@ -113,6 +121,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
boxShadow: 'var(--theme-effect-soft-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['chatRoomMainPanel'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
@@ -122,6 +131,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
boxShadow: 'var(--theme-effect-panel-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['chatRoomMembersPanel'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.92)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
@@ -131,6 +141,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
boxShadow: 'var(--theme-effect-soft-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['chatRoomEmptyState'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.88)',
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
@@ -139,6 +150,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.08), transparent 45%)',
|
||||
boxShadow: 'var(--theme-effect-soft-shadow)'
|
||||
};
|
||||
|
||||
elements['voiceWorkspace'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background) / 0.74)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
@@ -148,6 +160,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
boxShadow: 'var(--theme-effect-panel-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['floatingVoiceControls'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.94)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
@@ -238,4 +251,4 @@ export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean {
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
|
||||
export const DEFAULT_THEME_JSON = JSON.stringify(DEFAULT_THEME_DOCUMENT, null, 2);
|
||||
export const DEFAULT_THEME_JSON = JSON.stringify(DEFAULT_THEME_DOCUMENT, null, 2);
|
||||
|
||||
@@ -130,4 +130,4 @@ export interface ThemeSchemaField<T extends string = string> {
|
||||
type: 'string' | 'number' | 'object';
|
||||
example: string | number;
|
||||
examples: readonly (string | number)[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
ThemeLayoutContainerDefinition,
|
||||
ThemeRegistryEntry
|
||||
} from './theme.models';
|
||||
import { ThemeLayoutContainerDefinition, ThemeRegistryEntry } from './theme.models';
|
||||
|
||||
export const THEME_LAYOUT_CONTAINERS: readonly ThemeLayoutContainerDefinition[] = [
|
||||
{
|
||||
@@ -158,4 +155,4 @@ export function getPickerVisibleThemeKeys(): string[] {
|
||||
return THEME_REGISTRY
|
||||
.filter((entry) => entry.pickerVisible)
|
||||
.map((entry) => entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,28 +96,44 @@ export const THEME_GRID_FIELDS: readonly ThemeSchemaField[] = [
|
||||
description: 'Horizontal grid start column, zero-based.',
|
||||
type: 'number',
|
||||
example: 4,
|
||||
examples: [0, 1, 4]
|
||||
examples: [
|
||||
0,
|
||||
1,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'y',
|
||||
description: 'Vertical grid start row, zero-based.',
|
||||
type: 'number',
|
||||
example: 0,
|
||||
examples: [0, 1, 6]
|
||||
examples: [
|
||||
0,
|
||||
1,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'w',
|
||||
description: 'Grid width in columns.',
|
||||
type: 'number',
|
||||
example: 12,
|
||||
examples: [1, 4, 12]
|
||||
examples: [
|
||||
1,
|
||||
4,
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'h',
|
||||
description: 'Grid height in rows.',
|
||||
type: 'number',
|
||||
example: 12,
|
||||
examples: [1, 4, 12]
|
||||
examples: [
|
||||
1,
|
||||
4,
|
||||
12
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -127,14 +143,22 @@ export const THEME_ANIMATION_FIELDS: readonly ThemeSchemaField[] = [
|
||||
description: 'Animation duration.',
|
||||
type: 'string',
|
||||
example: '240ms',
|
||||
examples: ['200ms', '240ms', '600ms']
|
||||
examples: [
|
||||
'200ms',
|
||||
'240ms',
|
||||
'600ms'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'easing',
|
||||
description: 'Animation easing function.',
|
||||
type: 'string',
|
||||
example: 'ease-out',
|
||||
examples: ['ease', 'ease-out', 'cubic-bezier(0.16, 1, 0.3, 1)']
|
||||
examples: [
|
||||
'ease',
|
||||
'ease-out',
|
||||
'cubic-bezier(0.16, 1, 0.3, 1)'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'delay',
|
||||
@@ -155,14 +179,23 @@ export const THEME_ANIMATION_FIELDS: readonly ThemeSchemaField[] = [
|
||||
description: 'Animation fill behavior after running.',
|
||||
type: 'string',
|
||||
example: 'both',
|
||||
examples: ['none', 'forwards', 'backwards', 'both']
|
||||
examples: [
|
||||
'none',
|
||||
'forwards',
|
||||
'backwards',
|
||||
'both'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'direction',
|
||||
description: 'Animation direction.',
|
||||
type: 'string',
|
||||
example: 'normal',
|
||||
examples: ['normal', 'reverse', 'alternate']
|
||||
examples: [
|
||||
'normal',
|
||||
'reverse',
|
||||
'alternate'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'keyframes',
|
||||
@@ -179,14 +212,22 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
|
||||
description: 'CSS width applied to the selected element host.',
|
||||
type: 'string',
|
||||
example: '280px',
|
||||
examples: ['280px', '20rem', 'min(24rem, 30vw)']
|
||||
examples: [
|
||||
'280px',
|
||||
'20rem',
|
||||
'min(24rem, 30vw)'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'height',
|
||||
description: 'CSS height applied to the selected element host.',
|
||||
type: 'string',
|
||||
example: '100%',
|
||||
examples: ['100%', '22rem', 'calc(100vh - 4rem)']
|
||||
examples: [
|
||||
'100%',
|
||||
'22rem',
|
||||
'calc(100vh - 4rem)'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'minWidth',
|
||||
@@ -221,56 +262,89 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
|
||||
description: 'CSS positioning mode for the host element.',
|
||||
type: 'string',
|
||||
example: 'relative',
|
||||
examples: ['static', 'relative', 'absolute', 'sticky']
|
||||
examples: [
|
||||
'static',
|
||||
'relative',
|
||||
'absolute',
|
||||
'sticky'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'top',
|
||||
description: 'CSS top inset used with positioned elements.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['0', '12px', '2rem']
|
||||
examples: [
|
||||
'0',
|
||||
'12px',
|
||||
'2rem'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'right',
|
||||
description: 'CSS right inset used with positioned elements.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['0', '12px', '2rem']
|
||||
examples: [
|
||||
'0',
|
||||
'12px',
|
||||
'2rem'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'bottom',
|
||||
description: 'CSS bottom inset used with positioned elements.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['0', '12px', '2rem']
|
||||
examples: [
|
||||
'0',
|
||||
'12px',
|
||||
'2rem'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'left',
|
||||
description: 'CSS left inset used with positioned elements.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['0', '12px', '2rem']
|
||||
examples: [
|
||||
'0',
|
||||
'12px',
|
||||
'2rem'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'opacity',
|
||||
description: 'Element opacity between 0 and 1.',
|
||||
type: 'number',
|
||||
example: 0.96,
|
||||
examples: [0.72, 0.88, 1]
|
||||
examples: [
|
||||
0.72,
|
||||
0.88,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'padding',
|
||||
description: 'CSS padding shorthand for internal spacing.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['12px', '12px 16px', '1rem 1.25rem']
|
||||
examples: [
|
||||
'12px',
|
||||
'12px 16px',
|
||||
'1rem 1.25rem'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'margin',
|
||||
description: 'CSS margin shorthand for external spacing.',
|
||||
type: 'string',
|
||||
example: '0',
|
||||
examples: ['0', '12px', '0 0 12px']
|
||||
examples: [
|
||||
'0',
|
||||
'12px',
|
||||
'0 0 12px'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'border',
|
||||
@@ -284,7 +358,11 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
|
||||
description: 'CSS border radius shorthand.',
|
||||
type: 'string',
|
||||
example: '16px',
|
||||
examples: ['12px', '16px', '999px']
|
||||
examples: [
|
||||
'12px',
|
||||
'16px',
|
||||
'999px'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'backgroundColor',
|
||||
@@ -312,21 +390,33 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
|
||||
description: 'CSS background-size value.',
|
||||
type: 'string',
|
||||
example: 'cover',
|
||||
examples: ['cover', 'contain', 'auto 100%']
|
||||
examples: [
|
||||
'cover',
|
||||
'contain',
|
||||
'auto 100%'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'backgroundPosition',
|
||||
description: 'CSS background-position value.',
|
||||
type: 'string',
|
||||
example: 'center',
|
||||
examples: ['center', 'top left', '50% 20%']
|
||||
examples: [
|
||||
'center',
|
||||
'top left',
|
||||
'50% 20%'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'backgroundRepeat',
|
||||
description: 'CSS background-repeat value.',
|
||||
type: 'string',
|
||||
example: 'no-repeat',
|
||||
examples: ['no-repeat', 'repeat', 'repeat-x']
|
||||
examples: [
|
||||
'no-repeat',
|
||||
'repeat',
|
||||
'repeat-x'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'gradient',
|
||||
@@ -429,4 +519,4 @@ export function createAnimationStarterDefinition(): ThemeDocument['animations'][
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,58 @@ import {
|
||||
getLayoutEditableThemeKeys
|
||||
} from './theme.registry';
|
||||
|
||||
const TOP_LEVEL_KEYS = ['meta', 'tokens', 'layout', 'elements', 'animations'] as const;
|
||||
const META_KEYS = ['name', 'version', 'description'] as const;
|
||||
const TOKEN_GROUP_KEYS = ['colors', 'spacing', 'radii', 'effects'] as const;
|
||||
const TOP_LEVEL_KEYS = [
|
||||
'meta',
|
||||
'tokens',
|
||||
'layout',
|
||||
'elements',
|
||||
'animations'
|
||||
] as const;
|
||||
const META_KEYS = [
|
||||
'name',
|
||||
'version',
|
||||
'description'
|
||||
] as const;
|
||||
const TOKEN_GROUP_KEYS = [
|
||||
'colors',
|
||||
'spacing',
|
||||
'radii',
|
||||
'effects'
|
||||
] as const;
|
||||
const LAYOUT_ENTRY_KEYS = ['container', 'grid'] as const;
|
||||
const GRID_KEYS = ['x', 'y', 'w', 'h'] as const;
|
||||
const ANIMATION_KEYS = ['duration', 'easing', 'delay', 'iterationCount', 'fillMode', 'direction', 'keyframes'] as const;
|
||||
const POSITION_VALUES = ['static', 'relative', 'absolute', 'sticky'] as const;
|
||||
const FILL_MODE_VALUES = ['none', 'forwards', 'backwards', 'both'] as const;
|
||||
const DIRECTION_VALUES = ['normal', 'reverse', 'alternate', 'alternate-reverse'] as const;
|
||||
const GRID_KEYS = [
|
||||
'x',
|
||||
'y',
|
||||
'w',
|
||||
'h'
|
||||
] as const;
|
||||
const ANIMATION_KEYS = [
|
||||
'duration',
|
||||
'easing',
|
||||
'delay',
|
||||
'iterationCount',
|
||||
'fillMode',
|
||||
'direction',
|
||||
'keyframes'
|
||||
] as const;
|
||||
const POSITION_VALUES = [
|
||||
'static',
|
||||
'relative',
|
||||
'absolute',
|
||||
'sticky'
|
||||
] as const;
|
||||
const FILL_MODE_VALUES = [
|
||||
'none',
|
||||
'forwards',
|
||||
'backwards',
|
||||
'both'
|
||||
] as const;
|
||||
const DIRECTION_VALUES = [
|
||||
'normal',
|
||||
'reverse',
|
||||
'alternate',
|
||||
'alternate-reverse'
|
||||
] as const;
|
||||
const SAFE_LINK_PROTOCOLS = ['http:', 'https:'] as const;
|
||||
const SAFE_CLASS_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
||||
const KEYFRAME_STEP_PATTERN = /^(from|to|(?:\d|[1-9]\d|100)%)$/;
|
||||
@@ -185,6 +228,7 @@ function validateElementStyles(value: unknown, path: string, errors: string[]):
|
||||
errors.push(`${path}.${key} must be a valid absolute URL.`);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -192,6 +236,7 @@ function validateElementStyles(value: unknown, path: string, errors: string[]):
|
||||
if (validateString(fieldValue, `${path}.${key}`, errors) && !SAFE_CLASS_PATTERN.test(fieldValue)) {
|
||||
errors.push(`${path}.${key} must be a safe CSS class token.`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -449,4 +494,4 @@ export function validateThemeDocument(input: unknown): ThemeValidationResult {
|
||||
errors: [],
|
||||
value: normaliseThemeDocument(input as Partial<ThemeDocument>)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,4 +132,4 @@ export class ThemeGridEditorComponent {
|
||||
private clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
insert: nextValue
|
||||
}
|
||||
});
|
||||
|
||||
this.isApplyingExternalValue = false;
|
||||
});
|
||||
|
||||
@@ -203,6 +204,7 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
selection: EditorSelection.range(selectionStart, selectionEnd),
|
||||
effects: EditorView.scrollIntoView(selectionStart, { y: 'center' })
|
||||
});
|
||||
|
||||
this.editorView.focus();
|
||||
}
|
||||
|
||||
@@ -242,4 +244,4 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export class ThemeSettingsComponent {
|
||||
readonly animationKeys = this.theme.knownAnimationClasses;
|
||||
readonly layoutContainers = this.layoutSync.containers();
|
||||
readonly themeEntries = this.registry.entries();
|
||||
readonly workspaceTabs: ReadonlyArray<{ key: ThemeStudioWorkspace; label: string; description: string }> = [
|
||||
readonly workspaceTabs: readonly { key: ThemeStudioWorkspace; label: string; description: string }[] = [
|
||||
{
|
||||
key: 'editor',
|
||||
label: 'JSON Editor',
|
||||
@@ -129,7 +129,8 @@ export class ThemeSettingsComponent {
|
||||
return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable);
|
||||
});
|
||||
readonly filteredEntries = computed(() => {
|
||||
const query = this.explorerQuery().trim().toLowerCase();
|
||||
const query = this.explorerQuery().trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
return this.mountedEntries();
|
||||
@@ -470,4 +471,4 @@ export class ThemeSettingsComponent {
|
||||
|
||||
return text.indexOf(`"${key}"`, sectionIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
HostListener,
|
||||
effect,
|
||||
inject,
|
||||
input
|
||||
input,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
|
||||
import { ExternalLinkService } from '../../../core/platform';
|
||||
@@ -24,7 +25,7 @@ function looksLikeImageReference(value: string): boolean {
|
||||
selector: '[appThemeNode]',
|
||||
standalone: true
|
||||
})
|
||||
export class ThemeNodeDirective {
|
||||
export class ThemeNodeDirective implements OnDestroy {
|
||||
readonly themeKey = input.required<string>({ alias: 'appThemeNode' });
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
@@ -245,4 +246,4 @@ export class ThemeNodeDirective {
|
||||
iconTarget.style.backgroundImage = 'none';
|
||||
iconTarget.textContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,4 +55,4 @@ export class ThemePickerOverlayComponent {
|
||||
cancel(): void {
|
||||
this.picker.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,4 @@ export * from './domain/theme.schema';
|
||||
export * from './domain/theme.validation';
|
||||
|
||||
export { ThemeNodeDirective } from './feature/theme-node.directive';
|
||||
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay.component';
|
||||
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay.component';
|
||||
|
||||
@@ -203,7 +203,7 @@ export class ThemeLibraryStorageService {
|
||||
isValid: true,
|
||||
modifiedAt: file.modifiedAt,
|
||||
themeName: result.value.meta.name,
|
||||
version: result.value.meta.version,
|
||||
version: result.value.meta.version
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -218,4 +218,4 @@ export class ThemeLibraryStorageService {
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
STORAGE_KEY_THEME_ACTIVE,
|
||||
STORAGE_KEY_THEME_DRAFT
|
||||
} from '../../../core/constants';
|
||||
import { STORAGE_KEY_THEME_ACTIVE, STORAGE_KEY_THEME_DRAFT } from '../../../core/constants';
|
||||
|
||||
export interface ThemeStorageSnapshot {
|
||||
activeText: string | null;
|
||||
@@ -41,4 +38,4 @@ export function saveActiveThemeText(text: string): void {
|
||||
|
||||
export function saveDraftThemeText(text: string): void {
|
||||
writeStoredThemeText(STORAGE_KEY_THEME_DRAFT, text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,7 @@ import { Store } from '@ngrx/store';
|
||||
import { STORAGE_KEY_USER_VOLUMES } from '../../../core/constants';
|
||||
import { ScreenShareFacade } from '../../../domains/screen-share';
|
||||
import { User } from '../../../shared-kernel';
|
||||
import {
|
||||
selectAllUsers,
|
||||
selectCurrentUser
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { VoiceConnectionFacade } from './voice-connection.facade';
|
||||
|
||||
export interface PlaybackOptions {
|
||||
|
||||
@@ -282,6 +282,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateCameraState({
|
||||
userId: user.id,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
STORAGE_KEY_GENERAL_SETTINGS,
|
||||
STORAGE_KEY_LAST_VIEWED_CHAT
|
||||
} from '../../core/constants';
|
||||
import { STORAGE_KEY_GENERAL_SETTINGS, STORAGE_KEY_LAST_VIEWED_CHAT } from '../../core/constants';
|
||||
|
||||
export interface GeneralSettings {
|
||||
reopenLastViewedChat: boolean;
|
||||
|
||||
44
toju-app/src/app/shared-kernel/access-control.models.ts
Normal file
44
toju-app/src/app/shared-kernel/access-control.models.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export const ROOM_PERMISSION_KEYS = [
|
||||
'manageServer',
|
||||
'manageRoles',
|
||||
'manageChannels',
|
||||
'manageIcon',
|
||||
'kickMembers',
|
||||
'banMembers',
|
||||
'manageBans',
|
||||
'deleteMessages',
|
||||
'joinVoice',
|
||||
'shareScreen',
|
||||
'uploadFiles'
|
||||
] as const;
|
||||
|
||||
export type RoomPermissionKey = typeof ROOM_PERMISSION_KEYS[number];
|
||||
|
||||
export type PermissionState = 'allow' | 'deny' | 'inherit';
|
||||
|
||||
export type ChannelPermissionTargetType = 'role' | 'user';
|
||||
|
||||
export type RoomPermissionMatrix = Partial<Record<RoomPermissionKey, PermissionState>>;
|
||||
|
||||
export interface RoomRole {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
position: number;
|
||||
isSystem?: boolean;
|
||||
permissions?: RoomPermissionMatrix;
|
||||
}
|
||||
|
||||
export interface RoomRoleAssignment {
|
||||
userId: string;
|
||||
oderId?: string;
|
||||
roleIds: string[];
|
||||
}
|
||||
|
||||
export interface ChannelPermissionOverride {
|
||||
channelId: string;
|
||||
targetType: ChannelPermissionTargetType;
|
||||
targetId: string;
|
||||
permission: RoomPermissionKey;
|
||||
value: PermissionState;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './user.models';
|
||||
export * from './room.models';
|
||||
export * from './access-control.models';
|
||||
export * from './message.models';
|
||||
export * from './moderation.models';
|
||||
export * from './voice-state.models';
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { RoomMember } from './user.models';
|
||||
import type {
|
||||
ChannelPermissionOverride,
|
||||
RoomRole,
|
||||
RoomRoleAssignment
|
||||
} from './access-control.models';
|
||||
|
||||
export type ChannelType = 'text' | 'voice';
|
||||
|
||||
@@ -23,9 +28,13 @@ export interface Room {
|
||||
maxUsers?: number;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
slowModeInterval?: number;
|
||||
permissions?: RoomPermissions;
|
||||
channels?: Channel[];
|
||||
members?: RoomMember[];
|
||||
roles?: RoomRole[];
|
||||
roleAssignments?: RoomRoleAssignment[];
|
||||
channelPermissions?: ChannelPermissionOverride[];
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { CameraState, VoiceState, ScreenShareState } from './voice-state.models';
|
||||
import type {
|
||||
CameraState,
|
||||
VoiceState,
|
||||
ScreenShareState
|
||||
} from './voice-state.models';
|
||||
|
||||
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
|
||||
|
||||
@@ -29,6 +33,7 @@ export interface RoomMember {
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
role: UserRole;
|
||||
roleIds?: string[];
|
||||
joinedAt: number;
|
||||
lastSeenAt: number;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from '../../shared-kernel';
|
||||
import { hydrateMessages } from './messages.helpers';
|
||||
import { canEditMessage } from '../../domains/chat/domain/message.rules';
|
||||
import { resolveRoomPermission } from '../../domains/access-control';
|
||||
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
|
||||
|
||||
@Injectable()
|
||||
@@ -244,16 +245,17 @@ export class MessagesEffects {
|
||||
adminDeleteMessage$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.adminDeleteMessage),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
mergeMap(([{ messageId }, currentUser]) => {
|
||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([
|
||||
{ messageId },
|
||||
currentUser,
|
||||
currentRoom
|
||||
]) => {
|
||||
if (!currentUser) {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Not logged in' }));
|
||||
}
|
||||
|
||||
const hasPermission =
|
||||
currentUser.role === 'host' ||
|
||||
currentUser.role === 'admin' ||
|
||||
currentUser.role === 'moderator';
|
||||
const hasPermission = !!currentRoom && resolveRoomPermission(currentRoom, currentUser, 'deleteMessages');
|
||||
|
||||
if (!hasPermission) {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' }));
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
Channel,
|
||||
ChannelType
|
||||
} from '../../shared-kernel';
|
||||
import { Channel, ChannelType } from '../../shared-kernel';
|
||||
|
||||
export function normalizeChannelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
|
||||
@@ -16,6 +16,16 @@ function fallbackUsername(member: Partial<RoomMember>): string {
|
||||
return base || member.oderId || member.id || 'user';
|
||||
}
|
||||
|
||||
function normalizeRoleIds(roleIds: readonly string[] | undefined): string[] | undefined {
|
||||
if (!Array.isArray(roleIds)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = Array.from(new Set(roleIds.filter((roleId): roleId is string => typeof roleId === 'string' && roleId.trim().length > 0).map((roleId) => roleId.trim())));
|
||||
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
|
||||
const key = getRoomMemberKey(member);
|
||||
const lastSeenAt =
|
||||
@@ -36,6 +46,7 @@ function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
|
||||
displayName: fallbackDisplayName(member),
|
||||
avatarUrl: member.avatarUrl || undefined,
|
||||
role: member.role || 'member',
|
||||
roleIds: normalizeRoleIds(member.roleIds),
|
||||
joinedAt,
|
||||
lastSeenAt
|
||||
};
|
||||
@@ -93,6 +104,9 @@ function mergeMembers(
|
||||
? (normalizedIncoming.avatarUrl || normalizedExisting.avatarUrl)
|
||||
: (normalizedExisting.avatarUrl || normalizedIncoming.avatarUrl),
|
||||
role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming),
|
||||
roleIds: preferIncoming
|
||||
? (normalizedIncoming.roleIds || normalizedExisting.roleIds)
|
||||
: (normalizedExisting.roleIds || normalizedIncoming.roleIds),
|
||||
joinedAt: Math.min(normalizedExisting.joinedAt, normalizedIncoming.joinedAt),
|
||||
lastSeenAt: Math.max(normalizedExisting.lastSeenAt, normalizedIncoming.lastSeenAt)
|
||||
};
|
||||
|
||||
@@ -58,6 +58,10 @@ export const RoomsActions = createActionGroup({
|
||||
'Update Room Settings Failure': props<{ error: string }>(),
|
||||
|
||||
'Update Room Permissions': props<{ roomId: string; permissions: Partial<RoomPermissions> }>(),
|
||||
'Update Room Access Control': props<{
|
||||
roomId: string;
|
||||
changes: Pick<Partial<Room>, 'roles' | 'roleAssignments' | 'channelPermissions' | 'slowModeInterval'>;
|
||||
}>(),
|
||||
|
||||
'Update Server Icon': props<{ roomId: string; icon: string }>(),
|
||||
'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),
|
||||
|
||||
@@ -2,19 +2,13 @@
|
||||
/* eslint-disable id-length */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars,, complexity */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
NavigationEnd,
|
||||
Router
|
||||
} from '@angular/router';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import {
|
||||
Actions,
|
||||
createEffect,
|
||||
ofType
|
||||
} from '@ngrx/effects';
|
||||
import {
|
||||
Action,
|
||||
Store
|
||||
} from '@ngrx/store';
|
||||
import { Action, Store } from '@ngrx/store';
|
||||
import {
|
||||
of,
|
||||
from,
|
||||
@@ -53,6 +47,12 @@ import {
|
||||
type ServerSourceSelector,
|
||||
ServerDirectoryFacade
|
||||
} from '../../domains/server-directory';
|
||||
import {
|
||||
normalizeRoomAccessControl,
|
||||
resolveLegacyRole,
|
||||
resolveRoomPermission,
|
||||
withLegacyRoomPermissions
|
||||
} from '../../domains/access-control';
|
||||
import {
|
||||
ChatEvent,
|
||||
Room,
|
||||
@@ -392,6 +392,10 @@ export class RoomsEffects {
|
||||
...room,
|
||||
isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate,
|
||||
channels: resolveRoomChannels(room.channels, serverInfo?.channels),
|
||||
slowModeInterval: serverInfo?.slowModeInterval ?? room.slowModeInterval,
|
||||
roles: serverInfo?.roles ?? room.roles,
|
||||
roleAssignments: serverInfo?.roleAssignments ?? room.roleAssignments,
|
||||
channelPermissions: serverInfo?.channelPermissions ?? room.channelPermissions,
|
||||
sourceId: serverInfo?.sourceId ?? room.sourceId,
|
||||
sourceName: serverInfo?.sourceName ?? room.sourceName,
|
||||
sourceUrl: serverInfo?.sourceUrl ?? room.sourceUrl,
|
||||
@@ -406,6 +410,10 @@ export class RoomsEffects {
|
||||
sourceName: resolvedRoom.sourceName,
|
||||
sourceUrl: resolvedRoom.sourceUrl,
|
||||
channels: resolvedRoom.channels,
|
||||
slowModeInterval: resolvedRoom.slowModeInterval,
|
||||
roles: resolvedRoom.roles,
|
||||
roleAssignments: resolvedRoom.roleAssignments,
|
||||
channelPermissions: resolvedRoom.channelPermissions,
|
||||
hasPassword: resolvedRoom.hasPassword,
|
||||
isPrivate: resolvedRoom.isPrivate
|
||||
});
|
||||
@@ -426,6 +434,10 @@ export class RoomsEffects {
|
||||
userCount: 1,
|
||||
maxUsers: 50,
|
||||
channels: resolveRoomChannels(undefined, serverInfo.channels),
|
||||
slowModeInterval: serverInfo.slowModeInterval,
|
||||
roles: serverInfo.roles,
|
||||
roleAssignments: serverInfo.roleAssignments,
|
||||
channelPermissions: serverInfo.channelPermissions,
|
||||
sourceId: serverInfo.sourceId,
|
||||
sourceName: serverInfo.sourceName,
|
||||
sourceUrl: serverInfo.sourceUrl
|
||||
@@ -451,6 +463,10 @@ export class RoomsEffects {
|
||||
userCount: serverData.userCount,
|
||||
maxUsers: serverData.maxUsers,
|
||||
channels: resolveRoomChannels(undefined, serverData.channels),
|
||||
slowModeInterval: serverData.slowModeInterval,
|
||||
roles: serverData.roles,
|
||||
roleAssignments: serverData.roleAssignments,
|
||||
channelPermissions: serverData.channelPermissions,
|
||||
sourceId: serverData.sourceId,
|
||||
sourceName: serverData.sourceName,
|
||||
sourceUrl: serverData.sourceUrl
|
||||
@@ -498,105 +514,102 @@ export class RoomsEffects {
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Remembers the viewed room whenever a room becomes active. */
|
||||
persistLastViewedChatOnRoomActivation$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
tap(([
|
||||
{ room },
|
||||
currentUser
|
||||
]) => {
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const persisted = loadLastViewedChatFromStorage(currentUser.id);
|
||||
const channelId = persisted?.roomId === room.id
|
||||
? resolveTextChannelId(room.channels, persisted.channelId)
|
||||
: resolveTextChannelId(room.channels);
|
||||
|
||||
saveLastViewedChatToStorage({
|
||||
userId: currentUser.id,
|
||||
roomId: room.id,
|
||||
channelId
|
||||
});
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Remembers the currently selected text channel for the active room. */
|
||||
persistLastViewedChatOnChannelSelection$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.selectChannel),
|
||||
withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectCurrentUser)),
|
||||
tap(([
|
||||
{ channelId },
|
||||
currentRoom,
|
||||
currentUser
|
||||
]) => {
|
||||
if (!currentRoom || !currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedChannelId = resolveTextChannelId(currentRoom.channels, channelId);
|
||||
|
||||
if (!resolvedChannelId || resolvedChannelId !== channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveLastViewedChatToStorage({
|
||||
userId: currentUser.id,
|
||||
roomId: currentRoom.id,
|
||||
channelId
|
||||
});
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Restores the last viewed text channel once the active room's channels are known. */
|
||||
restoreLastViewedTextChannel$ = createEffect(() =>
|
||||
/** Remembers the viewed room whenever a room becomes active. */
|
||||
persistLastViewedChatOnRoomActivation$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(
|
||||
RoomsActions.createRoomSuccess,
|
||||
RoomsActions.joinRoomSuccess,
|
||||
RoomsActions.viewServerSuccess,
|
||||
RoomsActions.updateRoom
|
||||
),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectActiveChannelId)
|
||||
),
|
||||
mergeMap(([
|
||||
, currentUser,
|
||||
currentRoom,
|
||||
activeChannelId
|
||||
]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
return EMPTY;
|
||||
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
tap(([{ room }, currentUser]) => {
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const persisted = loadLastViewedChatFromStorage(currentUser.id);
|
||||
const channelId = persisted?.roomId === room.id
|
||||
? resolveTextChannelId(room.channels, persisted.channelId)
|
||||
: resolveTextChannelId(room.channels);
|
||||
|
||||
if (!persisted || persisted.roomId !== currentRoom.id) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const channelId = resolveTextChannelId(currentRoom.channels, persisted.channelId);
|
||||
|
||||
if (!channelId || channelId === activeChannelId) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return of(RoomsActions.selectChannel({ channelId }));
|
||||
saveLastViewedChatToStorage({
|
||||
userId: currentUser.id,
|
||||
roomId: room.id,
|
||||
channelId
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Remembers the currently selected text channel for the active room. */
|
||||
persistLastViewedChatOnChannelSelection$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.selectChannel),
|
||||
withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectCurrentUser)),
|
||||
tap(([
|
||||
{ channelId },
|
||||
currentRoom,
|
||||
currentUser
|
||||
]) => {
|
||||
if (!currentRoom || !currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedChannelId = resolveTextChannelId(currentRoom.channels, channelId);
|
||||
|
||||
if (!resolvedChannelId || resolvedChannelId !== channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveLastViewedChatToStorage({
|
||||
userId: currentUser.id,
|
||||
roomId: currentRoom.id,
|
||||
channelId
|
||||
});
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Restores the last viewed text channel once the active room's channels are known. */
|
||||
restoreLastViewedTextChannel$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(
|
||||
RoomsActions.createRoomSuccess,
|
||||
RoomsActions.joinRoomSuccess,
|
||||
RoomsActions.viewServerSuccess,
|
||||
RoomsActions.updateRoom
|
||||
),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectActiveChannelId)
|
||||
),
|
||||
mergeMap(([
|
||||
, currentUser,
|
||||
currentRoom,
|
||||
activeChannelId
|
||||
]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const persisted = loadLastViewedChatFromStorage(currentUser.id);
|
||||
|
||||
if (!persisted || persisted.roomId !== currentRoom.id) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const channelId = resolveTextChannelId(currentRoom.channels, persisted.channelId);
|
||||
|
||||
if (!channelId || channelId === activeChannelId) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return of(RoomsActions.selectChannel({ channelId }));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
refreshServerOwnedRoomMetadata$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
@@ -621,6 +634,10 @@ export class RoomsEffects {
|
||||
isPrivate: serverData.isPrivate,
|
||||
maxUsers: serverData.maxUsers,
|
||||
channels: resolveRoomChannels(room.channels, serverData.channels),
|
||||
slowModeInterval: serverData.slowModeInterval ?? room.slowModeInterval,
|
||||
roles: serverData.roles ?? room.roles,
|
||||
roleAssignments: serverData.roleAssignments ?? room.roleAssignments,
|
||||
channelPermissions: serverData.channelPermissions ?? room.channelPermissions,
|
||||
sourceId: serverData.sourceId ?? room.sourceId,
|
||||
sourceName: serverData.sourceName ?? room.sourceName,
|
||||
sourceUrl: serverData.sourceUrl ?? room.sourceUrl
|
||||
@@ -856,7 +873,7 @@ export class RoomsEffects {
|
||||
|
||||
const currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom);
|
||||
const isOwner = currentUserRole === 'host';
|
||||
const canManageRoom = currentUserRole === 'host' || currentUserRole === 'admin';
|
||||
const canManageRoom = isOwner || resolveRoomPermission(room, currentUser, 'manageServer');
|
||||
|
||||
if (!canManageRoom) {
|
||||
return of(
|
||||
@@ -949,7 +966,10 @@ export class RoomsEffects {
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
tap(([, currentUser, currentRoom]) => {
|
||||
tap(([
|
||||
, currentUser,
|
||||
currentRoom
|
||||
]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
return;
|
||||
}
|
||||
@@ -1008,27 +1028,93 @@ export class RoomsEffects {
|
||||
if (!room)
|
||||
return EMPTY;
|
||||
|
||||
const isOwner =
|
||||
room.hostId === currentUser.id ||
|
||||
room.hostId === currentUser.oderId ||
|
||||
(currentRoom?.id === room.id && currentUser.role === 'host');
|
||||
const nextRoom = withLegacyRoomPermissions(room, permissions);
|
||||
|
||||
if (!isOwner)
|
||||
return of(RoomsActions.updateRoomAccessControl({
|
||||
roomId: room.id,
|
||||
changes: {
|
||||
roles: nextRoom.roles,
|
||||
roleAssignments: nextRoom.roleAssignments,
|
||||
channelPermissions: nextRoom.channelPermissions,
|
||||
slowModeInterval: nextRoom.slowModeInterval
|
||||
}
|
||||
}));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
updateRoomAccessControl$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.updateRoomAccessControl),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectSavedRooms)
|
||||
),
|
||||
mergeMap(([
|
||||
{ roomId, changes },
|
||||
currentUser,
|
||||
currentRoom,
|
||||
savedRooms
|
||||
]) => {
|
||||
if (!currentUser)
|
||||
return EMPTY;
|
||||
|
||||
const updated: Partial<Room> = {
|
||||
permissions: { ...(room.permissions || {}),
|
||||
...permissions } as RoomPermissions
|
||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||
|
||||
if (!room)
|
||||
return EMPTY;
|
||||
|
||||
const requiresRoleManagement = !!changes.roles || !!changes.roleAssignments || !!changes.channelPermissions;
|
||||
const requiresServerManagement = typeof changes.slowModeInterval === 'number';
|
||||
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
|
||||
const canManageRoles = isOwner || resolveRoomPermission(room, currentUser, 'manageRoles');
|
||||
const canManageServer = isOwner || resolveRoomPermission(room, currentUser, 'manageServer');
|
||||
|
||||
if ((requiresRoleManagement && !canManageRoles) || (requiresServerManagement && !canManageServer)) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const nextRoom = normalizeRoomAccessControl({
|
||||
...room,
|
||||
...changes
|
||||
});
|
||||
const nextChanges: Partial<Room> = {
|
||||
roles: nextRoom.roles,
|
||||
roleAssignments: nextRoom.roleAssignments,
|
||||
channelPermissions: nextRoom.channelPermissions,
|
||||
slowModeInterval: nextRoom.slowModeInterval,
|
||||
permissions: nextRoom.permissions,
|
||||
members: nextRoom.members
|
||||
};
|
||||
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'room-permissions-update',
|
||||
roomId: room.id,
|
||||
permissions: updated.permissions
|
||||
permissions: nextRoom.permissions,
|
||||
room: {
|
||||
roles: nextRoom.roles,
|
||||
roleAssignments: nextRoom.roleAssignments,
|
||||
channelPermissions: nextRoom.channelPermissions,
|
||||
slowModeInterval: nextRoom.slowModeInterval
|
||||
}
|
||||
});
|
||||
|
||||
this.serverDirectory.updateServer(room.id, {
|
||||
currentOwnerId: currentUser.id,
|
||||
roles: nextRoom.roles,
|
||||
roleAssignments: nextRoom.roleAssignments,
|
||||
channelPermissions: nextRoom.channelPermissions,
|
||||
slowModeInterval: nextRoom.slowModeInterval
|
||||
}, {
|
||||
sourceId: room.sourceId,
|
||||
sourceUrl: room.sourceUrl
|
||||
}).subscribe({
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
return of(RoomsActions.updateRoom({ roomId: room.id,
|
||||
changes: updated }));
|
||||
changes: nextChanges }));
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -1047,12 +1133,8 @@ export class RoomsEffects {
|
||||
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' }));
|
||||
}
|
||||
|
||||
const role = currentUser.role;
|
||||
const perms = currentRoom.permissions || {};
|
||||
const isOwner = currentRoom.hostId === currentUser.id;
|
||||
const canByRole =
|
||||
(role === 'admin' && perms.adminsManageIcon) ||
|
||||
(role === 'moderator' && perms.moderatorsManageIcon);
|
||||
const canByRole = resolveRoomPermission(currentRoom, currentUser, 'manageIcon');
|
||||
|
||||
if (!isOwner && !canByRole) {
|
||||
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
|
||||
@@ -1472,6 +1554,7 @@ export class RoomsEffects {
|
||||
serverDescription: room.description,
|
||||
serverRoute: `/room/${room.id}`
|
||||
});
|
||||
|
||||
this.voiceSessionService.setViewingVoiceServer(wasViewingVoiceServer);
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
@@ -1522,9 +1605,13 @@ export class RoomsEffects {
|
||||
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
|
||||
icon: typeof room.icon === 'string' ? room.icon : undefined,
|
||||
iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined,
|
||||
slowModeInterval: typeof room.slowModeInterval === 'number' ? room.slowModeInterval : undefined,
|
||||
permissions: room.permissions ? { ...room.permissions } : undefined,
|
||||
channels: Array.isArray(room.channels) ? room.channels : undefined,
|
||||
members: Array.isArray(room.members) ? room.members : undefined,
|
||||
roles: Array.isArray(room.roles) ? room.roles : undefined,
|
||||
roleAssignments: Array.isArray(room.roleAssignments) ? room.roleAssignments : undefined,
|
||||
channelPermissions: Array.isArray(room.channelPermissions) ? room.channelPermissions : undefined,
|
||||
sourceId: typeof room.sourceId === 'string' ? room.sourceId : undefined,
|
||||
sourceName: typeof room.sourceName === 'string' ? room.sourceName : undefined,
|
||||
sourceUrl: typeof room.sourceUrl === 'string' ? room.sourceUrl : undefined
|
||||
@@ -1665,16 +1752,23 @@ export class RoomsEffects {
|
||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
|
||||
const incomingRoom = event.room as Partial<Room> | undefined;
|
||||
|
||||
if (!room || !permissions)
|
||||
if (!room || (!permissions && !incomingRoom))
|
||||
return EMPTY;
|
||||
|
||||
return of(
|
||||
RoomsActions.updateRoom({
|
||||
roomId: room.id,
|
||||
changes: {
|
||||
permissions: { ...(room.permissions || {}),
|
||||
...permissions } as RoomPermissions
|
||||
permissions: permissions
|
||||
? { ...(room.permissions || {}),
|
||||
...permissions } as RoomPermissions
|
||||
: room.permissions,
|
||||
roles: Array.isArray(incomingRoom?.roles) ? incomingRoom.roles : room.roles,
|
||||
roleAssignments: Array.isArray(incomingRoom?.roleAssignments) ? incomingRoom.roleAssignments : room.roleAssignments,
|
||||
channelPermissions: Array.isArray(incomingRoom?.channelPermissions) ? incomingRoom.channelPermissions : room.channelPermissions,
|
||||
slowModeInterval: typeof incomingRoom?.slowModeInterval === 'number' ? incomingRoom.slowModeInterval : room.slowModeInterval
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -1764,11 +1858,8 @@ export class RoomsEffects {
|
||||
if (!sender)
|
||||
return EMPTY;
|
||||
|
||||
const perms = room.permissions || {};
|
||||
const isOwner = room.hostId === sender.id;
|
||||
const canByRole =
|
||||
(sender.role === 'admin' && perms.adminsManageIcon) ||
|
||||
(sender.role === 'moderator' && perms.moderatorsManageIcon);
|
||||
const canByRole = resolveRoomPermission(room, sender, 'manageIcon');
|
||||
|
||||
if (!isOwner && !canByRole)
|
||||
return EMPTY;
|
||||
@@ -2004,15 +2095,7 @@ export class RoomsEffects {
|
||||
}
|
||||
|
||||
private getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
|
||||
if (room.hostId === currentUser.id || room.hostId === currentUser.oderId)
|
||||
return 'host';
|
||||
|
||||
if (currentRoom?.id === room.id && currentUser.role)
|
||||
return currentUser.role;
|
||||
|
||||
return findRoomMember(room.members ?? [], currentUser.id)?.role
|
||||
|| findRoomMember(room.members ?? [], currentUser.oderId)?.role
|
||||
|| null;
|
||||
return resolveLegacyRole(currentRoom?.id === room.id ? currentRoom : room, currentUser);
|
||||
}
|
||||
|
||||
private canManageChannelsInRoom(
|
||||
@@ -2021,21 +2104,7 @@ export class RoomsEffects {
|
||||
currentRoom: Room | null,
|
||||
currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom)
|
||||
): boolean {
|
||||
if (currentUserRole === 'host') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const permissions = room.permissions || {};
|
||||
|
||||
if (currentUserRole === 'admin' && permissions.adminsManageRooms) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentUserRole === 'moderator' && permissions.moderatorsManageRooms) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return currentUserRole === 'host' || resolveRoomPermission(room, currentUser, 'manageChannels');
|
||||
}
|
||||
|
||||
private getPersistedCurrentUserId(): string | null {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createReducer, on } from '@ngrx/store';
|
||||
import {
|
||||
Room,
|
||||
RoomSettings
|
||||
} from '../../shared-kernel';
|
||||
import { Room, RoomSettings } from '../../shared-kernel';
|
||||
import { normalizeRoomAccessControl } from '../../domains/access-control';
|
||||
import { type ServerInfo } from '../../domains/server-directory';
|
||||
import { RoomsActions } from './rooms.actions';
|
||||
import { defaultChannels } from './room-channels.defaults';
|
||||
@@ -26,12 +24,12 @@ function deduplicateRooms(rooms: Room[]): Room[] {
|
||||
|
||||
/** Normalize room defaults and prune any stale persisted member entries. */
|
||||
function enrichRoom(room: Room): Room {
|
||||
return {
|
||||
return normalizeRoomAccessControl({
|
||||
...room,
|
||||
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
||||
channels: normalizeRoomChannels(room.channels) || defaultChannels(),
|
||||
members: pruneRoomMembers(room.members || [])
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveActiveTextChannelId(channels: Room['channels'], currentActiveChannelId: string): string {
|
||||
@@ -422,8 +420,11 @@ export const roomsReducer = createReducer(
|
||||
return state;
|
||||
}
|
||||
|
||||
const updatedChannels = [...existing, { ...channel,
|
||||
name: normalizedName }];
|
||||
const updatedChannels = [
|
||||
...existing,
|
||||
{ ...channel,
|
||||
name: normalizedName }
|
||||
];
|
||||
const updatedRoom = { ...state.currentRoom,
|
||||
channels: updatedChannels };
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import {
|
||||
canManageMember,
|
||||
resolveLegacyRole,
|
||||
resolveRoomPermission
|
||||
} from '../../domains/access-control';
|
||||
import {
|
||||
BanEntry,
|
||||
ChatEvent,
|
||||
@@ -157,7 +162,7 @@ export class UsersEffects {
|
||||
if (!room)
|
||||
return EMPTY;
|
||||
|
||||
const canKick = this.canKickInRoom(room, currentUser, currentRoom);
|
||||
const canKick = this.canKickInRoom(room, currentUser, currentRoom, userId);
|
||||
|
||||
if (!canKick)
|
||||
return EMPTY;
|
||||
@@ -227,7 +232,7 @@ export class UsersEffects {
|
||||
if (!room)
|
||||
return EMPTY;
|
||||
|
||||
const canBan = this.canBanInRoom(room, currentUser, currentRoom);
|
||||
const canBan = this.canBanInRoom(room, currentUser, currentRoom, userId);
|
||||
|
||||
if (!canBan)
|
||||
return EMPTY;
|
||||
@@ -487,31 +492,19 @@ export class UsersEffects {
|
||||
private canModerateRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
|
||||
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
|
||||
|
||||
return role === 'host' || role === 'admin';
|
||||
return role === 'host' || resolveRoomPermission(room, currentUser, 'manageBans');
|
||||
}
|
||||
|
||||
private canKickInRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
|
||||
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
|
||||
|
||||
return role === 'host' || role === 'admin' || role === 'moderator';
|
||||
private canKickInRoom(room: Room, currentUser: User, currentRoom: Room | null, targetUserId: string): boolean {
|
||||
return canManageMember(room, currentUser, findRoomMember(room.members ?? [], targetUserId) ?? { id: targetUserId }, 'kickMembers');
|
||||
}
|
||||
|
||||
private canBanInRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
|
||||
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
|
||||
|
||||
return role === 'host' || role === 'admin';
|
||||
private canBanInRoom(room: Room, currentUser: User, currentRoom: Room | null, targetUserId: string): boolean {
|
||||
return canManageMember(room, currentUser, findRoomMember(room.members ?? [], targetUserId) ?? { id: targetUserId }, 'banMembers');
|
||||
}
|
||||
|
||||
private getCurrentUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
|
||||
return (
|
||||
room.hostId === currentUser.id || room.hostId === currentUser.oderId
|
||||
)
|
||||
? 'host'
|
||||
: (currentRoom?.id === room.id
|
||||
? currentUser.role
|
||||
: (findRoomMember(room.members ?? [], currentUser.id)?.role
|
||||
|| findRoomMember(room.members ?? [], currentUser.oderId)?.role
|
||||
|| null));
|
||||
return resolveLegacyRole(currentRoom?.id === room.id ? currentRoom : room, currentUser);
|
||||
}
|
||||
|
||||
private removeMemberFromRoom(room: Room, targetUserId: string): Partial<Room> {
|
||||
|
||||
Reference in New Issue
Block a user