Add access control rework

This commit is contained in:
2026-04-02 03:18:37 +02:00
parent 314a26325f
commit 37cac95b38
111 changed files with 5355 additions and 1892 deletions

View File

@@ -96,8 +96,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2.15MB"
"maximumWarning": "2.2MB",
"maximumError": "2.3MB"
},
{
"type": "anyComponentStyle",

View File

@@ -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>
}

View File

@@ -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();
}

View File

@@ -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/` |

View 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`.

View File

@@ -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.'
}
];

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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
);
}

View 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);
}

View 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)
};
}

View 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';

View File

@@ -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">

View File

@@ -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">

View File

@@ -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);

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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) => {

View File

@@ -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"

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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.`);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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');

View File

@@ -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);

View File

@@ -130,4 +130,4 @@ export interface ThemeSchemaField<T extends string = string> {
type: 'string' | 'number' | 'object';
example: string | number;
examples: readonly (string | number)[];
}
}

View File

@@ -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);
}
}

View File

@@ -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'][
}
}
};
}
}

View File

@@ -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>)
};
}
}

View File

@@ -132,4 +132,4 @@ export class ThemeGridEditorComponent {
private clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum);
}
}
}

View File

@@ -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 {
});
});
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 = '';
}
}
}

View File

@@ -55,4 +55,4 @@ export class ThemePickerOverlayComponent {
cancel(): void {
this.picker.cancel();
}
}
}

View File

@@ -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';

View File

@@ -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 {
};
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -282,6 +282,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}
})
);
this.store.dispatch(
UsersActions.updateCameraState({
userId: user.id,

View File

@@ -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>
}

View File

@@ -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 }));
}
}

View File

@@ -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',

View File

@@ -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>
}

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View 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;
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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' }));

View File

@@ -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, ' ');

View File

@@ -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)
};

View File

@@ -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 }>(),

View File

@@ -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 {

View File

@@ -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 };

View File

@@ -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> {