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

@@ -5,6 +5,9 @@ import {
RoomEntity, RoomEntity,
RoomChannelEntity, RoomChannelEntity,
RoomMemberEntity, RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,
@@ -17,6 +20,9 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
await dataSource.getRepository(RoomEntity).clear(); await dataSource.getRepository(RoomEntity).clear();
await dataSource.getRepository(RoomChannelEntity).clear(); await dataSource.getRepository(RoomChannelEntity).clear();
await dataSource.getRepository(RoomMemberEntity).clear(); await dataSource.getRepository(RoomMemberEntity).clear();
await dataSource.getRepository(RoomRoleEntity).clear();
await dataSource.getRepository(RoomUserRoleEntity).clear();
await dataSource.getRepository(RoomChannelPermissionEntity).clear();
await dataSource.getRepository(ReactionEntity).clear(); await dataSource.getRepository(ReactionEntity).clear();
await dataSource.getRepository(BanEntity).clear(); await dataSource.getRepository(BanEntity).clear();
await dataSource.getRepository(AttachmentEntity).clear(); await dataSource.getRepository(AttachmentEntity).clear();

View File

@@ -1,8 +1,11 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { import {
RoomChannelPermissionEntity,
RoomChannelEntity, RoomChannelEntity,
RoomEntity, RoomEntity,
RoomMemberEntity, RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
MessageEntity MessageEntity
} from '../../../entities'; } from '../../../entities';
import { DeleteRoomCommand } from '../../types'; import { DeleteRoomCommand } from '../../types';
@@ -11,8 +14,11 @@ export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: D
const { roomId } = command.payload; const { roomId } = command.payload;
await dataSource.transaction(async (manager) => { await dataSource.transaction(async (manager) => {
await manager.getRepository(RoomChannelPermissionEntity).delete({ roomId });
await manager.getRepository(RoomChannelEntity).delete({ roomId }); await manager.getRepository(RoomChannelEntity).delete({ roomId });
await manager.getRepository(RoomMemberEntity).delete({ roomId }); await manager.getRepository(RoomMemberEntity).delete({ roomId });
await manager.getRepository(RoomRoleEntity).delete({ roomId });
await manager.getRepository(RoomUserRoleEntity).delete({ roomId });
await manager.getRepository(RoomEntity).delete({ id: roomId }); await manager.getRepository(RoomEntity).delete({ id: roomId });
await manager.getRepository(MessageEntity).delete({ roomId }); await manager.getRepository(MessageEntity).delete({ roomId });
}); });

View File

@@ -5,6 +5,7 @@ import { SaveMessageCommand } from '../../types';
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> { export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
const { message } = command.payload; const { message } = command.payload;
await dataSource.transaction(async (manager) => { await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(MessageEntity); const repo = manager.getRepository(MessageEntity);
const entity = repo.create({ const entity = repo.create({

View File

@@ -3,8 +3,23 @@ import { RoomEntity } from '../../../entities';
import { replaceRoomRelations } from '../../relations'; import { replaceRoomRelations } from '../../relations';
import { SaveRoomCommand } from '../../types'; import { SaveRoomCommand } from '../../types';
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
return room.slowModeInterval;
}
const permissions = room.permissions && typeof room.permissions === 'object'
? room.permissions as { slowModeInterval?: unknown }
: null;
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
? permissions.slowModeInterval
: 0;
}
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> { export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
const { room } = command.payload; const { room } = command.payload;
await dataSource.transaction(async (manager) => { await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(RoomEntity); const repo = manager.getRepository(RoomEntity);
const entity = repo.create({ const entity = repo.create({
@@ -21,7 +36,7 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
maxUsers: room.maxUsers ?? null, maxUsers: room.maxUsers ?? null,
icon: room.icon ?? null, icon: room.icon ?? null,
iconUpdatedAt: room.iconUpdatedAt ?? null, iconUpdatedAt: room.iconUpdatedAt ?? null,
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null, slowModeInterval: extractSlowModeInterval(room),
sourceId: room.sourceId ?? null, sourceId: room.sourceId ?? null,
sourceName: room.sourceName ?? null, sourceName: room.sourceName ?? null,
sourceUrl: room.sourceUrl ?? null sourceUrl: room.sourceUrl ?? null
@@ -30,7 +45,11 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
await repo.save(entity); await repo.save(entity);
await replaceRoomRelations(manager, room.id, { await replaceRoomRelations(manager, room.id, {
channels: room.channels ?? [], channels: room.channels ?? [],
members: room.members ?? [] members: room.members ?? [],
roles: room.roles ?? [],
roleAssignments: room.roleAssignments ?? [],
channelPermissions: room.channelPermissions ?? [],
permissions: room.permissions
}); });
}); });
} }

View File

@@ -5,6 +5,7 @@ import { UpdateMessageCommand } from '../../types';
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> { export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
const { messageId, updates } = command.payload; const { messageId, updates } = command.payload;
await dataSource.transaction(async (manager) => { await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(MessageEntity); const repo = manager.getRepository(MessageEntity);
const existing = await repo.findOne({ where: { id: messageId } }); const existing = await repo.findOne({ where: { id: messageId } });

View File

@@ -5,19 +5,32 @@ import { UpdateRoomCommand } from '../../types';
import { import {
applyUpdates, applyUpdates,
boolToInt, boolToInt,
jsonOrNull,
TransformMap TransformMap
} from './utils/applyUpdates'; } from './utils/applyUpdates';
const ROOM_TRANSFORMS: TransformMap = { const ROOM_TRANSFORMS: TransformMap = {
hasPassword: boolToInt, hasPassword: boolToInt,
isPrivate: boolToInt, isPrivate: boolToInt,
userCount: (val) => (val ?? 0), userCount: (val) => (val ?? 0)
permissions: jsonOrNull
}; };
function extractSlowModeInterval(updates: UpdateRoomCommand['payload']['updates']): number | undefined {
if (typeof updates.slowModeInterval === 'number' && Number.isFinite(updates.slowModeInterval)) {
return updates.slowModeInterval;
}
const permissions = updates.permissions && typeof updates.permissions === 'object'
? updates.permissions as { slowModeInterval?: unknown }
: null;
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
? permissions.slowModeInterval
: undefined;
}
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> { export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
const { roomId, updates } = command.payload; const { roomId, updates } = command.payload;
await dataSource.transaction(async (manager) => { await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(RoomEntity); const repo = manager.getRepository(RoomEntity);
const existing = await repo.findOne({ where: { id: roomId } }); const existing = await repo.findOne({ where: { id: roomId } });
@@ -25,13 +38,30 @@ export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: D
if (!existing) if (!existing)
return; return;
const { channels, members, ...entityUpdates } = updates; const {
channels,
members,
roles,
roleAssignments,
channelPermissions,
permissions: rawPermissions,
...entityUpdates
} = updates;
const slowModeInterval = extractSlowModeInterval(updates);
if (slowModeInterval !== undefined) {
entityUpdates.slowModeInterval = slowModeInterval;
}
applyUpdates(existing, entityUpdates, ROOM_TRANSFORMS); applyUpdates(existing, entityUpdates, ROOM_TRANSFORMS);
await repo.save(existing); await repo.save(existing);
await replaceRoomRelations(manager, roomId, { await replaceRoomRelations(manager, roomId, {
channels, channels,
members members,
roles,
roleAssignments,
channelPermissions,
permissions: rawPermissions
}); });
}); });
} }

View File

@@ -9,10 +9,15 @@ import { RoomEntity } from '../entities/RoomEntity';
import { ReactionEntity } from '../entities/ReactionEntity'; import { ReactionEntity } from '../entities/ReactionEntity';
import { BanEntity } from '../entities/BanEntity'; import { BanEntity } from '../entities/BanEntity';
import { AttachmentEntity } from '../entities/AttachmentEntity'; import { AttachmentEntity } from '../entities/AttachmentEntity';
import { ReactionPayload } from './types';
import { import {
ReactionPayload, relationRecordToRoomPayload,
RoomPayload RoomChannelPermissionRecord,
} from './types'; RoomChannelRecord,
RoomMemberRecord,
RoomRoleAssignmentRecord,
RoomRoleRecord
} from './relations';
const DELETED_MESSAGE_CONTENT = '[Message deleted]'; const DELETED_MESSAGE_CONTENT = '[Message deleted]';
@@ -55,8 +60,28 @@ export function rowToUser(row: UserEntity) {
export function rowToRoom( export function rowToRoom(
row: RoomEntity, row: RoomEntity,
relations: Pick<RoomPayload, 'channels' | 'members'> = { channels: [], members: [] } relations: {
channels?: RoomChannelRecord[];
members?: RoomMemberRecord[];
roles?: RoomRoleRecord[];
roleAssignments?: RoomRoleAssignmentRecord[];
channelPermissions?: RoomChannelPermissionRecord[];
} = {
channels: [],
members: [],
roles: [],
roleAssignments: [],
channelPermissions: []
}
) { ) {
const relationPayload = relationRecordToRoomPayload({ slowModeInterval: row.slowModeInterval }, {
channels: relations.channels ?? [],
members: relations.members ?? [],
roles: relations.roles ?? [],
roleAssignments: relations.roleAssignments ?? [],
channelPermissions: relations.channelPermissions ?? []
});
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
@@ -71,9 +96,13 @@ export function rowToRoom(
maxUsers: row.maxUsers ?? undefined, maxUsers: row.maxUsers ?? undefined,
icon: row.icon ?? undefined, icon: row.icon ?? undefined,
iconUpdatedAt: row.iconUpdatedAt ?? undefined, iconUpdatedAt: row.iconUpdatedAt ?? undefined,
permissions: row.permissions ? JSON.parse(row.permissions) : undefined, slowModeInterval: row.slowModeInterval,
channels: relations.channels ?? [], permissions: relationPayload.permissions,
members: relations.members ?? [], channels: relationPayload.channels,
members: relationPayload.members,
roles: relationPayload.roles,
roleAssignments: relationPayload.roleAssignments,
channelPermissions: relationPayload.channelPermissions,
sourceId: row.sourceId ?? undefined, sourceId: row.sourceId ?? undefined,
sourceName: row.sourceName ?? undefined, sourceName: row.sourceName ?? undefined,
sourceUrl: row.sourceUrl ?? undefined sourceUrl: row.sourceUrl ?? undefined

View File

@@ -6,13 +6,54 @@ import {
import { import {
ReactionEntity, ReactionEntity,
RoomChannelEntity, RoomChannelEntity,
RoomMemberEntity RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity
} from '../entities'; } from '../entities';
import { ReactionPayload } from './types'; import {
AccessRolePayload,
ChannelPermissionPayload,
PermissionStatePayload,
ReactionPayload,
RoleAssignmentPayload,
RoomPayload,
RoomPermissionKeyPayload
} from './types';
type ChannelType = 'text' | 'voice'; type ChannelType = 'text' | 'voice';
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member'; type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
interface LegacyRoomPermissions {
adminsManageRooms?: boolean;
moderatorsManageRooms?: boolean;
adminsManageIcon?: boolean;
moderatorsManageIcon?: boolean;
allowVoice?: boolean;
allowScreenShare?: boolean;
allowFileUploads?: boolean;
slowModeInterval?: number;
}
const ROOM_PERMISSION_KEYS: RoomPermissionKeyPayload[] = [
'manageServer',
'manageRoles',
'manageChannels',
'manageIcon',
'kickMembers',
'banMembers',
'manageBans',
'deleteMessages',
'joinVoice',
'shareScreen',
'uploadFiles'
];
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
export interface RoomChannelRecord { export interface RoomChannelRecord {
id: string; id: string;
name: string; name: string;
@@ -27,13 +68,23 @@ export interface RoomMemberRecord {
displayName: string; displayName: string;
avatarUrl?: string; avatarUrl?: string;
role: RoomMemberRole; role: RoomMemberRole;
roleIds?: string[];
joinedAt: number; joinedAt: number;
lastSeenAt: number; lastSeenAt: number;
} }
export type RoomRoleRecord = AccessRolePayload;
export type RoomRoleAssignmentRecord = RoleAssignmentPayload;
export type RoomChannelPermissionRecord = ChannelPermissionPayload;
interface RoomRelationRecord { interface RoomRelationRecord {
channels: RoomChannelRecord[]; channels: RoomChannelRecord[];
members: RoomMemberRecord[]; members: RoomMemberRecord[];
roles: RoomRoleRecord[];
roleAssignments: RoomRoleAssignmentRecord[];
channelPermissions: RoomChannelPermissionRecord[];
} }
function isFiniteNumber(value: unknown): value is number { function isFiniteNumber(value: unknown): value is number {
@@ -52,6 +103,56 @@ function memberKey(member: { id?: string; oderId?: string }): string {
return member.oderId?.trim() || member.id?.trim() || ''; return member.oderId?.trim() || member.id?.trim() || '';
} }
function compareText(firstValue: string, secondValue: string): number {
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
}
function uniqueStrings(values: readonly string[] | undefined): string[] {
return Array.from(new Set((values ?? [])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())));
}
function trimmedString(record: Record<string, unknown>, key: string): string {
return typeof record[key] === 'string' ? record[key].trim() : '';
}
function resolveRoomMemberTimes(rawMember: Record<string, unknown>, now: number): Pick<RoomMemberRecord, 'joinedAt' | 'lastSeenAt'> {
const lastSeenAt = isFiniteNumber(rawMember['lastSeenAt'])
? rawMember['lastSeenAt']
: isFiniteNumber(rawMember['joinedAt'])
? rawMember['joinedAt']
: now;
return {
joinedAt: isFiniteNumber(rawMember['joinedAt']) ? rawMember['joinedAt'] : lastSeenAt,
lastSeenAt
};
}
function normalizePermissionState(value: unknown): PermissionStatePayload {
return value === 'allow' || value === 'deny' || value === 'inherit'
? value
: 'inherit';
}
function normalizePermissionMatrix(rawMatrix: unknown): Partial<Record<RoomPermissionKeyPayload, PermissionStatePayload>> {
const matrix = rawMatrix && typeof rawMatrix === 'object'
? rawMatrix as Record<string, unknown>
: {};
const normalized: Partial<Record<RoomPermissionKeyPayload, PermissionStatePayload>> = {};
for (const key of ROOM_PERMISSION_KEYS) {
const value = normalizePermissionState(matrix[key]);
if (value !== 'inherit') {
normalized[key] = value;
}
}
return normalized;
}
function fallbackDisplayName(member: Partial<RoomMemberRecord>): string { function fallbackDisplayName(member: Partial<RoomMemberRecord>): string {
return member.displayName || member.username || member.oderId || member.id || 'User'; return member.displayName || member.username || member.oderId || member.id || 'User';
} }
@@ -92,46 +193,161 @@ function mergeRoomMemberRole(
} }
function compareRoomMembers(firstMember: RoomMemberRecord, secondMember: RoomMemberRecord): number { function compareRoomMembers(firstMember: RoomMemberRecord, secondMember: RoomMemberRecord): number {
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' }); const displayNameCompare = compareText(firstMember.displayName, secondMember.displayName);
if (displayNameCompare !== 0) { if (displayNameCompare !== 0) {
return displayNameCompare; return displayNameCompare;
} }
return memberKey(firstMember).localeCompare(memberKey(secondMember)); return compareText(memberKey(firstMember), memberKey(secondMember));
}
function compareRoles(firstRole: RoomRoleRecord, secondRole: RoomRoleRecord): number {
if (firstRole.position !== secondRole.position) {
return firstRole.position - secondRole.position;
}
return compareText(firstRole.name, secondRole.name);
}
function compareAssignments(firstAssignment: RoomRoleAssignmentRecord, secondAssignment: RoomRoleAssignmentRecord): number {
return compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId);
}
function parseLegacyPermissions(rawPermissions: unknown): LegacyRoomPermissions {
if (!rawPermissions || typeof rawPermissions !== 'object') {
return {};
}
const permissions = rawPermissions as Record<string, unknown>;
return {
adminsManageRooms: permissions['adminsManageRooms'] === true,
moderatorsManageRooms: permissions['moderatorsManageRooms'] === true,
adminsManageIcon: permissions['adminsManageIcon'] === true,
moderatorsManageIcon: permissions['moderatorsManageIcon'] === true,
allowVoice: permissions['allowVoice'] !== false,
allowScreenShare: permissions['allowScreenShare'] !== false,
allowFileUploads: permissions['allowFileUploads'] !== false,
slowModeInterval: isFiniteNumber(permissions['slowModeInterval']) ? permissions['slowModeInterval'] : 0
};
}
function buildDefaultRoomRoles(legacyPermissions: LegacyRoomPermissions): RoomRoleRecord[] {
return [
{
id: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: true,
permissions: {
joinVoice: legacyPermissions.allowVoice === false ? 'deny' : 'allow',
shareScreen: legacyPermissions.allowScreenShare === false ? 'deny' : 'allow',
uploadFiles: legacyPermissions.allowFileUploads === false ? 'deny' : 'allow'
}
},
{
id: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: true,
permissions: {
kickMembers: 'allow',
deleteMessages: 'allow',
manageChannels: legacyPermissions.moderatorsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions.moderatorsManageIcon ? 'allow' : 'inherit'
}
},
{
id: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: true,
permissions: {
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
manageChannels: legacyPermissions.adminsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions.adminsManageIcon ? 'allow' : 'inherit'
}
}
];
}
function normalizeRoomRole(rawRole: Partial<RoomRoleRecord>, fallbackRole?: RoomRoleRecord): RoomRoleRecord | null {
const id = typeof rawRole.id === 'string' ? rawRole.id.trim() : fallbackRole?.id ?? '';
const name = typeof rawRole.name === 'string' ? rawRole.name.trim().replace(/\s+/g, ' ') : fallbackRole?.name ?? '';
if (!id || !name) {
return null;
}
return {
id,
name,
color: typeof rawRole.color === 'string' && rawRole.color.trim() ? rawRole.color.trim() : fallbackRole?.color,
position: isFiniteNumber(rawRole.position) ? rawRole.position : fallbackRole?.position ?? 0,
isSystem: typeof rawRole.isSystem === 'boolean' ? rawRole.isSystem : fallbackRole?.isSystem,
permissions: normalizePermissionMatrix(rawRole.permissions ?? fallbackRole?.permissions)
};
}
export function normalizeRoomRoles(rawRoles: unknown, rawPermissions: unknown): RoomRoleRecord[] {
const defaults = buildDefaultRoomRoles(parseLegacyPermissions(rawPermissions));
const rolesById = new Map<string, RoomRoleRecord>();
if (Array.isArray(rawRoles)) {
for (const rawRole of rawRoles) {
if (!rawRole || typeof rawRole !== 'object') {
continue;
}
const normalizedRole = normalizeRoomRole(rawRole as Record<string, unknown>);
if (normalizedRole) {
rolesById.set(normalizedRole.id, normalizedRole);
}
}
}
for (const defaultRole of defaults) {
const mergedRole = normalizeRoomRole(rolesById.get(defaultRole.id) ?? defaultRole, defaultRole) ?? defaultRole;
rolesById.set(defaultRole.id, mergedRole);
}
return Array.from(rolesById.values()).sort(compareRoles);
} }
function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): RoomMemberRecord | null { function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): RoomMemberRecord | null {
const normalizedId = typeof rawMember['id'] === 'string' ? rawMember['id'].trim() : ''; const normalizedId = trimmedString(rawMember, 'id');
const normalizedOderId = typeof rawMember['oderId'] === 'string' ? rawMember['oderId'].trim() : ''; const normalizedOderId = trimmedString(rawMember, 'oderId');
const normalizedKey = normalizedOderId || normalizedId; const normalizedKey = normalizedOderId || normalizedId;
if (!normalizedKey) { if (!normalizedKey) {
return null; return null;
} }
const lastSeenAt = isFiniteNumber(rawMember['lastSeenAt']) const { joinedAt, lastSeenAt } = resolveRoomMemberTimes(rawMember, now);
? rawMember['lastSeenAt'] const username = trimmedString(rawMember, 'username');
: isFiniteNumber(rawMember['joinedAt']) const displayName = trimmedString(rawMember, 'displayName');
? rawMember['joinedAt'] const avatarUrl = trimmedString(rawMember, 'avatarUrl');
: now;
const joinedAt = isFiniteNumber(rawMember['joinedAt']) ? rawMember['joinedAt'] : lastSeenAt;
const username = typeof rawMember['username'] === 'string' ? rawMember['username'].trim() : '';
const displayName = typeof rawMember['displayName'] === 'string' ? rawMember['displayName'].trim() : '';
const avatarUrl = typeof rawMember['avatarUrl'] === 'string' ? rawMember['avatarUrl'].trim() : '';
const member: RoomMemberRecord = { return {
id: normalizedId || normalizedKey, id: normalizedId || normalizedKey,
oderId: normalizedOderId || undefined, oderId: normalizedOderId || undefined,
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }), username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }), displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
avatarUrl: avatarUrl || undefined, avatarUrl: avatarUrl || undefined,
role: normalizeRoomMemberRole(rawMember['role']), role: normalizeRoomMemberRole(rawMember['role']),
roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined),
joinedAt, joinedAt,
lastSeenAt lastSeenAt
}; };
return member;
} }
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord { function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
@@ -154,11 +370,176 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
? (incomingMember.avatarUrl || existingMember.avatarUrl) ? (incomingMember.avatarUrl || existingMember.avatarUrl)
: (existingMember.avatarUrl || incomingMember.avatarUrl), : (existingMember.avatarUrl || incomingMember.avatarUrl),
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming), role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
roleIds: preferIncoming
? (incomingMember.roleIds || existingMember.roleIds)
: (existingMember.roleIds || incomingMember.roleIds),
joinedAt: Math.min(existingMember.joinedAt, incomingMember.joinedAt), joinedAt: Math.min(existingMember.joinedAt, incomingMember.joinedAt),
lastSeenAt: Math.max(existingMember.lastSeenAt, incomingMember.lastSeenAt) lastSeenAt: Math.max(existingMember.lastSeenAt, incomingMember.lastSeenAt)
}; };
} }
export function normalizeRoomMembers(rawMembers: unknown, now: number = Date.now()): RoomMemberRecord[] {
if (!Array.isArray(rawMembers)) {
return [];
}
const membersByKey = new Map<string, RoomMemberRecord>();
for (const rawMember of rawMembers) {
if (!rawMember || typeof rawMember !== 'object') {
continue;
}
const member = normalizeRoomMember(rawMember as Record<string, unknown>, now);
if (!member) {
continue;
}
const key = memberKey(member);
membersByKey.set(key, mergeRoomMembers(membersByKey.get(key), member));
}
return Array.from(membersByKey.values()).sort(compareRoomMembers);
}
function buildRoleAssignmentsFromMembers(members: readonly RoomMemberRecord[], roles: readonly RoomRoleRecord[]): RoomRoleAssignmentRecord[] {
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
return members.flatMap((member) => {
const roleIds = uniqueStrings(member.roleIds)
.filter((roleId) => validRoleIds.has(roleId));
const fallbackRoleIds = roleIds.length > 0
? roleIds
: member.role === 'admin'
? [SYSTEM_ROLE_IDS.admin]
: member.role === 'moderator'
? [SYSTEM_ROLE_IDS.moderator]
: [];
if (fallbackRoleIds.length === 0) {
return [];
}
return [
{
userId: member.id,
oderId: member.oderId,
roleIds: fallbackRoleIds
}
];
}).sort(compareAssignments);
}
export function normalizeRoomRoleAssignments(
rawAssignments: unknown,
members: readonly RoomMemberRecord[],
roles: readonly RoomRoleRecord[]
): RoomRoleAssignmentRecord[] {
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
const assignmentsByKey = new Map<string, RoomRoleAssignmentRecord>();
if (Array.isArray(rawAssignments)) {
for (const rawAssignment of rawAssignments) {
if (!rawAssignment || typeof rawAssignment !== 'object') {
continue;
}
const assignment = rawAssignment as Record<string, unknown>;
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(Array.isArray(assignment['roleIds']) ? assignment['roleIds'] as string[] : undefined)
.filter((roleId) => validRoleIds.has(roleId));
if (roleIds.length === 0) {
continue;
}
assignmentsByKey.set(key, {
userId: userId || key,
oderId,
roleIds
});
}
}
if (assignmentsByKey.size === 0) {
return buildRoleAssignmentsFromMembers(members, roles);
}
return Array.from(assignmentsByKey.values()).sort(compareAssignments);
}
export function normalizeRoomChannelPermissions(
rawChannelPermissions: unknown,
roles: readonly RoomRoleRecord[]
): RoomChannelPermissionRecord[] {
if (!Array.isArray(rawChannelPermissions)) {
return [];
}
const validRoleIds = new Set(roles.map((role) => role.id));
const overridesByKey = new Map<string, RoomChannelPermissionRecord>();
for (const rawOverride of rawChannelPermissions) {
if (!rawOverride || typeof rawOverride !== 'object') {
continue;
}
const override = rawOverride as Record<string, unknown>;
const channelId = typeof override['channelId'] === 'string' ? override['channelId'].trim() : '';
const targetType = override['targetType'] === 'role' || override['targetType'] === 'user' ? override['targetType'] : null;
const targetId = typeof override['targetId'] === 'string' ? override['targetId'].trim() : '';
const permission = ROOM_PERMISSION_KEYS.find((key) => key === override['permission']);
const value = normalizePermissionState(override['value']);
if (!channelId || !targetType || !targetId || !permission || value === 'inherit') {
continue;
}
if (targetType === 'role' && !validRoleIds.has(targetId)) {
continue;
}
const key = `${channelId}:${targetType}:${targetId}:${permission}`;
overridesByKey.set(key, {
channelId,
targetType,
targetId,
permission,
value
});
}
return Array.from(overridesByKey.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);
});
}
function normalizeReactionPayloads(rawReactions: unknown, messageId: string): ReactionPayload[] { function normalizeReactionPayloads(rawReactions: unknown, messageId: string): ReactionPayload[] {
if (!Array.isArray(rawReactions)) { if (!Array.isArray(rawReactions)) {
return []; return [];
@@ -233,30 +614,34 @@ export function normalizeRoomChannels(rawChannels: unknown): RoomChannelRecord[]
return channels; return channels;
} }
export function normalizeRoomMembers(rawMembers: unknown, now: number = Date.now()): RoomMemberRecord[] { function deriveLegacyPermissions(roles: readonly RoomRoleRecord[], slowModeInterval: number): LegacyRoomPermissions {
if (!Array.isArray(rawMembers)) { const everyoneRole = roles.find((role) => role.id === SYSTEM_ROLE_IDS.everyone);
return []; const moderatorRole = roles.find((role) => role.id === SYSTEM_ROLE_IDS.moderator);
} const adminRole = roles.find((role) => role.id === SYSTEM_ROLE_IDS.admin);
const toBoolean = (role: RoomRoleRecord | undefined, permission: RoomPermissionKeyPayload, fallbackValue: boolean): boolean => {
const state = role?.permissions?.[permission];
const membersByKey = new Map<string, RoomMemberRecord>(); if (state === 'allow') {
return true;
for (const rawMember of rawMembers) {
if (!rawMember || typeof rawMember !== 'object') {
continue;
} }
const member = normalizeRoomMember(rawMember as Record<string, unknown>, now); if (state === 'deny') {
return false;
if (!member) {
continue;
} }
const key = memberKey(member); return fallbackValue;
};
membersByKey.set(key, mergeRoomMembers(membersByKey.get(key), member)); return {
} allowVoice: toBoolean(everyoneRole, 'joinVoice', true),
allowScreenShare: toBoolean(everyoneRole, 'shareScreen', true),
return Array.from(membersByKey.values()).sort(compareRoomMembers); allowFileUploads: toBoolean(everyoneRole, 'uploadFiles', true),
adminsManageRooms: adminRole?.permissions?.manageChannels === 'allow',
moderatorsManageRooms: moderatorRole?.permissions?.manageChannels === 'allow',
adminsManageIcon: adminRole?.permissions?.manageIcon === 'allow',
moderatorsManageIcon: moderatorRole?.permissions?.manageIcon === 'allow',
slowModeInterval
};
} }
export async function replaceMessageReactions( export async function replaceMessageReactions(
@@ -311,11 +696,12 @@ export async function loadMessageReactionsMap(
emoji: row.emoji, emoji: row.emoji,
timestamp: row.timestamp timestamp: row.timestamp
}); });
groupedReactions.set(row.messageId, reactions); groupedReactions.set(row.messageId, reactions);
} }
for (const reactions of groupedReactions.values()) { for (const reactions of groupedReactions.values()) {
reactions.sort((first, second) => first.timestamp - second.timestamp); reactions.sort((firstReaction, secondReaction) => firstReaction.timestamp - secondReaction.timestamp);
} }
return groupedReactions; return groupedReactions;
@@ -324,8 +710,22 @@ export async function loadMessageReactionsMap(
export async function replaceRoomRelations( export async function replaceRoomRelations(
manager: EntityManager, manager: EntityManager,
roomId: string, roomId: string,
options: { channels?: unknown; members?: unknown } options: {
channels?: unknown;
members?: unknown;
roles?: unknown;
roleAssignments?: unknown;
channelPermissions?: unknown;
permissions?: unknown;
}
): Promise<void> { ): Promise<void> {
const normalizedMembers = options.members !== undefined
? normalizeRoomMembers(options.members)
: [];
const normalizedRoles = options.roles !== undefined
? normalizeRoomRoles(options.roles, options.permissions)
: [];
if (options.channels !== undefined) { if (options.channels !== undefined) {
const channelRepo = manager.getRepository(RoomChannelEntity); const channelRepo = manager.getRepository(RoomChannelEntity);
const channels = normalizeRoomChannels(options.channels); const channels = normalizeRoomChannels(options.channels);
@@ -347,13 +747,12 @@ export async function replaceRoomRelations(
if (options.members !== undefined) { if (options.members !== undefined) {
const memberRepo = manager.getRepository(RoomMemberEntity); const memberRepo = manager.getRepository(RoomMemberEntity);
const members = normalizeRoomMembers(options.members);
await memberRepo.delete({ roomId }); await memberRepo.delete({ roomId });
if (members.length > 0) { if (normalizedMembers.length > 0) {
await memberRepo.insert( await memberRepo.insert(
members.map((member) => ({ normalizedMembers.map((member) => ({
roomId, roomId,
memberKey: memberKey(member), memberKey: memberKey(member),
id: member.id, id: member.id,
@@ -368,6 +767,84 @@ export async function replaceRoomRelations(
); );
} }
} }
if (options.roles !== undefined) {
const roleRepo = manager.getRepository(RoomRoleEntity);
await roleRepo.delete({ roomId });
if (normalizedRoles.length > 0) {
await roleRepo.insert(
normalizedRoles.map((role) => ({
roomId,
roleId: role.id,
name: role.name,
color: role.color ?? null,
position: role.position,
isSystem: role.isSystem ? 1 : 0,
manageServer: normalizePermissionState(role.permissions?.manageServer),
manageRoles: normalizePermissionState(role.permissions?.manageRoles),
manageChannels: normalizePermissionState(role.permissions?.manageChannels),
manageIcon: normalizePermissionState(role.permissions?.manageIcon),
kickMembers: normalizePermissionState(role.permissions?.kickMembers),
banMembers: normalizePermissionState(role.permissions?.banMembers),
manageBans: normalizePermissionState(role.permissions?.manageBans),
deleteMessages: normalizePermissionState(role.permissions?.deleteMessages),
joinVoice: normalizePermissionState(role.permissions?.joinVoice),
shareScreen: normalizePermissionState(role.permissions?.shareScreen),
uploadFiles: normalizePermissionState(role.permissions?.uploadFiles)
}))
);
}
}
if (options.roleAssignments !== undefined) {
const assignmentRepo = manager.getRepository(RoomUserRoleEntity);
const assignments = normalizeRoomRoleAssignments(
options.roleAssignments,
normalizedMembers,
normalizedRoles.length > 0 ? normalizedRoles : normalizeRoomRoles([], options.permissions)
);
await assignmentRepo.delete({ roomId });
const rows = assignments.flatMap((assignment) =>
assignment.roleIds.map((roleId) => ({
roomId,
userKey: assignment.oderId?.trim() || assignment.userId.trim(),
roleId,
userId: assignment.userId,
oderId: assignment.oderId ?? null
}))
);
if (rows.length > 0) {
await assignmentRepo.insert(rows);
}
}
if (options.channelPermissions !== undefined) {
const channelPermissionRepo = manager.getRepository(RoomChannelPermissionEntity);
const channelPermissions = normalizeRoomChannelPermissions(
options.channelPermissions,
normalizedRoles.length > 0 ? normalizedRoles : normalizeRoomRoles([], options.permissions)
);
await channelPermissionRepo.delete({ roomId });
if (channelPermissions.length > 0) {
await channelPermissionRepo.insert(
channelPermissions.map((channelPermission) => ({
roomId,
channelId: channelPermission.channelId,
targetType: channelPermission.targetType,
targetId: channelPermission.targetId,
permission: channelPermission.permission,
value: channelPermission.value
}))
);
}
}
} }
export async function loadRoomRelationsMap( export async function loadRoomRelationsMap(
@@ -380,31 +857,51 @@ export async function loadRoomRelationsMap(
return groupedRelations; return groupedRelations;
} }
const [channelRows, memberRows] = await Promise.all([ const [
channelRows,
memberRows,
roleRows,
assignmentRows,
channelPermissionRows
] = await Promise.all([
dataSource.getRepository(RoomChannelEntity).find({ dataSource.getRepository(RoomChannelEntity).find({
where: { roomId: In([...roomIds]) } where: { roomId: In([...roomIds]) }
}), }),
dataSource.getRepository(RoomMemberEntity).find({ dataSource.getRepository(RoomMemberEntity).find({
where: { roomId: In([...roomIds]) } where: { roomId: In([...roomIds]) }
}),
dataSource.getRepository(RoomRoleEntity).find({
where: { roomId: In([...roomIds]) }
}),
dataSource.getRepository(RoomUserRoleEntity).find({
where: { roomId: In([...roomIds]) }
}),
dataSource.getRepository(RoomChannelPermissionEntity).find({
where: { roomId: In([...roomIds]) }
}) })
]); ]);
for (const row of channelRows) { for (const roomId of roomIds) {
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] }; groupedRelations.set(roomId, {
channels: [],
members: [],
roles: [],
roleAssignments: [],
channelPermissions: []
});
}
relation.channels.push({ for (const row of channelRows) {
groupedRelations.get(row.roomId)?.channels.push({
id: row.channelId, id: row.channelId,
name: row.name, name: row.name,
type: row.type, type: row.type,
position: row.position position: row.position
}); });
groupedRelations.set(row.roomId, relation);
} }
for (const row of memberRows) { for (const row of memberRows) {
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] }; groupedRelations.get(row.roomId)?.members.push({
relation.members.push({
id: row.id, id: row.id,
oderId: row.oderId ?? undefined, oderId: row.oderId ?? undefined,
username: row.username, username: row.username,
@@ -414,13 +911,92 @@ export async function loadRoomRelationsMap(
joinedAt: row.joinedAt, joinedAt: row.joinedAt,
lastSeenAt: row.lastSeenAt lastSeenAt: row.lastSeenAt
}); });
groupedRelations.set(row.roomId, relation); }
for (const row of roleRows) {
groupedRelations.get(row.roomId)?.roles.push({
id: row.roleId,
name: row.name,
color: row.color ?? undefined,
position: row.position,
isSystem: !!row.isSystem,
permissions: normalizePermissionMatrix({
manageServer: row.manageServer,
manageRoles: row.manageRoles,
manageChannels: row.manageChannels,
manageIcon: row.manageIcon,
kickMembers: row.kickMembers,
banMembers: row.banMembers,
manageBans: row.manageBans,
deleteMessages: row.deleteMessages,
joinVoice: row.joinVoice,
shareScreen: row.shareScreen,
uploadFiles: row.uploadFiles
})
});
}
for (const row of assignmentRows) {
const relation = groupedRelations.get(row.roomId);
if (!relation) {
continue;
}
const existing = relation.roleAssignments.find((assignment) =>
assignment.userId === row.userId || assignment.oderId === row.oderId || (row.oderId == null && assignment.userId === row.userKey)
);
if (existing) {
existing.roleIds = uniqueStrings([...existing.roleIds, row.roleId]);
continue;
}
relation.roleAssignments.push({
userId: row.userId,
oderId: row.oderId ?? undefined,
roleIds: [row.roleId]
});
}
for (const row of channelPermissionRows) {
groupedRelations.get(row.roomId)?.channelPermissions.push({
channelId: row.channelId,
targetType: row.targetType,
targetId: row.targetId,
permission: row.permission as RoomPermissionKeyPayload,
value: normalizePermissionState(row.value)
});
} }
for (const relation of groupedRelations.values()) { for (const relation of groupedRelations.values()) {
relation.channels.sort((first, second) => first.position - second.position || first.name.localeCompare(second.name)); relation.channels.sort(
(firstChannel, secondChannel) => firstChannel.position - secondChannel.position || compareText(firstChannel.name, secondChannel.name)
);
relation.members.sort(compareRoomMembers); relation.members.sort(compareRoomMembers);
relation.roles.sort(compareRoles);
relation.roleAssignments.sort(compareAssignments);
} }
return groupedRelations; return groupedRelations;
} }
export function relationRecordToRoomPayload(
row: Pick<RoomPayload, 'slowModeInterval'>,
relations: RoomRelationRecord
): Pick<RoomPayload, 'permissions' | 'channels' | 'members' | 'roles' | 'roleAssignments' | 'channelPermissions'> {
const roleAssignmentsByKey = new Map(relations.roleAssignments.map((assignment) => [assignment.oderId || assignment.userId, assignment.roleIds]));
return {
permissions: deriveLegacyPermissions(relations.roles, row.slowModeInterval ?? 0),
channels: relations.channels,
members: relations.members.map((member) => ({
...member,
roleIds: roleAssignmentsByKey.get(member.oderId || member.id)
})),
roles: relations.roles,
roleAssignments: relations.roleAssignments,
channelPermissions: relations.channelPermissions
};
}

View File

@@ -61,6 +61,44 @@ export interface ReactionPayload {
timestamp: number; timestamp: number;
} }
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
export type RoomPermissionKeyPayload =
| 'manageServer'
| 'manageRoles'
| 'manageChannels'
| 'manageIcon'
| 'kickMembers'
| 'banMembers'
| 'manageBans'
| 'deleteMessages'
| 'joinVoice'
| 'shareScreen'
| 'uploadFiles';
export interface AccessRolePayload {
id: string;
name: string;
color?: string;
position: number;
isSystem?: boolean;
permissions?: Partial<Record<RoomPermissionKeyPayload, PermissionStatePayload>>;
}
export interface RoleAssignmentPayload {
userId: string;
oderId?: string;
roleIds: string[];
}
export interface ChannelPermissionPayload {
channelId: string;
targetType: 'role' | 'user';
targetId: string;
permission: RoomPermissionKeyPayload;
value: PermissionStatePayload;
}
export interface UserPayload { export interface UserPayload {
id: string; id: string;
oderId?: string; oderId?: string;
@@ -92,9 +130,13 @@ export interface RoomPayload {
maxUsers?: number; maxUsers?: number;
icon?: string; icon?: string;
iconUpdatedAt?: number; iconUpdatedAt?: number;
slowModeInterval?: number;
permissions?: unknown; permissions?: unknown;
channels?: unknown[]; channels?: unknown[];
members?: unknown[]; members?: unknown[];
roles?: AccessRolePayload[];
roleAssignments?: RoleAssignmentPayload[];
channelPermissions?: ChannelPermissionPayload[];
sourceId?: string; sourceId?: string;
sourceName?: string; sourceName?: string;
sourceUrl?: string; sourceUrl?: string;

View File

@@ -17,6 +17,11 @@ import {
MessageEntity, MessageEntity,
UserEntity, UserEntity,
RoomEntity, RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,
@@ -38,6 +43,11 @@ export const AppDataSource = new DataSource({
MessageEntity, MessageEntity,
UserEntity, UserEntity,
RoomEntity, RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,

View File

@@ -9,6 +9,9 @@ import {
RoomEntity, RoomEntity,
RoomChannelEntity, RoomChannelEntity,
RoomMemberEntity, RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,
@@ -44,6 +47,9 @@ export async function initializeDatabase(): Promise<void> {
RoomEntity, RoomEntity,
RoomChannelEntity, RoomChannelEntity,
RoomMemberEntity, RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity, ReactionEntity,
BanEntity, BanEntity,
AttachmentEntity, AttachmentEntity,

View File

@@ -20,4 +20,4 @@ export class RoomChannelEntity {
@Column('integer') @Column('integer')
position!: number; position!: number;
} }

View File

@@ -0,0 +1,26 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('room_channel_permissions')
export class RoomChannelPermissionEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
channelId!: string;
@PrimaryColumn('text')
targetType!: 'role' | 'user';
@PrimaryColumn('text')
targetId!: string;
@PrimaryColumn('text')
permission!: string;
@Column('text')
value!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -45,8 +45,8 @@ export class RoomEntity {
@Column('integer', { nullable: true }) @Column('integer', { nullable: true })
iconUpdatedAt!: number | null; iconUpdatedAt!: number | null;
@Column('text', { nullable: true }) @Column('integer', { default: 0 })
permissions!: string | null; slowModeInterval!: number;
@Column('text', { nullable: true }) @Column('text', { nullable: true })
sourceId!: string | null; sourceId!: string | null;

View File

@@ -35,4 +35,4 @@ export class RoomMemberEntity {
@Column('integer') @Column('integer')
lastSeenAt!: number; lastSeenAt!: number;
} }

View File

@@ -0,0 +1,59 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('room_roles')
export class RoomRoleEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text')
name!: string;
@Column('text', { nullable: true })
color!: string | null;
@Column('integer')
position!: number;
@Column('integer', { default: 0 })
isSystem!: number;
@Column('text', { default: 'inherit' })
manageServer!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageRoles!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageChannels!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageIcon!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
kickMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
banMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageBans!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
deleteMessages!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
joinVoice!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
shareScreen!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
uploadFiles!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -0,0 +1,23 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('room_user_roles')
export class RoomUserRoleEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
userKey!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text')
userId!: string;
@Column('text', { nullable: true })
oderId!: string | null;
}

View File

@@ -3,6 +3,9 @@ export { UserEntity } from './UserEntity';
export { RoomEntity } from './RoomEntity'; export { RoomEntity } from './RoomEntity';
export { RoomChannelEntity } from './RoomChannelEntity'; export { RoomChannelEntity } from './RoomChannelEntity';
export { RoomMemberEntity } from './RoomMemberEntity'; export { RoomMemberEntity } from './RoomMemberEntity';
export { RoomRoleEntity } from './RoomRoleEntity';
export { RoomUserRoleEntity } from './RoomUserRoleEntity';
export { RoomChannelPermissionEntity } from './RoomChannelPermissionEntity';
export { ReactionEntity } from './ReactionEntity'; export { ReactionEntity } from './ReactionEntity';
export { BanEntity } from './BanEntity'; export { BanEntity } from './BanEntity';
export { AttachmentEntity } from './AttachmentEntity'; export { AttachmentEntity } from './AttachmentEntity';

View File

@@ -338,6 +338,7 @@ export function setupSystemHandlers(): void {
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => { ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
return await writeSavedTheme(fileName, text); return await writeSavedTheme(fileName, text);
}); });
ipcMain.handle('delete-saved-theme', async (_event, fileName: string) => { ipcMain.handle('delete-saved-theme', async (_event, fileName: string) => {
return await deleteSavedTheme(fileName); return await deleteSavedTheme(fileName);
}); });

View File

@@ -0,0 +1,310 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyRoomRow = {
id: string;
name: string;
description: string | null;
topic: string | null;
hostId: string;
password: string | null;
hasPassword: number;
isPrivate: number;
createdAt: number;
userCount: number;
maxUsers: number | null;
icon: string | null;
iconUpdatedAt: number | null;
permissions: string | null;
sourceId: string | null;
sourceName: string | null;
sourceUrl: string | null;
};
type RoomMemberRow = {
roomId: string;
memberKey: string;
id: string;
oderId: string | null;
role: string;
};
type LegacyRoomPermissions = {
adminsManageRooms?: boolean;
moderatorsManageRooms?: boolean;
adminsManageIcon?: boolean;
moderatorsManageIcon?: boolean;
allowVoice?: boolean;
allowScreenShare?: boolean;
allowFileUploads?: boolean;
slowModeInterval?: number;
};
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
function parseLegacyPermissions(rawPermissions: string | null): LegacyRoomPermissions {
try {
const parsed = JSON.parse(rawPermissions || '{}') as Record<string, unknown>;
return {
adminsManageRooms: parsed['adminsManageRooms'] === true,
moderatorsManageRooms: parsed['moderatorsManageRooms'] === true,
adminsManageIcon: parsed['adminsManageIcon'] === true,
moderatorsManageIcon: parsed['moderatorsManageIcon'] === true,
allowVoice: parsed['allowVoice'] !== false,
allowScreenShare: parsed['allowScreenShare'] !== false,
allowFileUploads: parsed['allowFileUploads'] !== false,
slowModeInterval: typeof parsed['slowModeInterval'] === 'number' && Number.isFinite(parsed['slowModeInterval'])
? parsed['slowModeInterval']
: 0
};
} catch {
return {
allowVoice: true,
allowScreenShare: true,
allowFileUploads: true,
slowModeInterval: 0
};
}
}
function buildDefaultRoomRoles(legacyPermissions: LegacyRoomPermissions) {
return [
{
roleId: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'inherit',
manageIcon: 'inherit',
kickMembers: 'inherit',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'inherit',
joinVoice: legacyPermissions.allowVoice === false ? 'deny' : 'allow',
shareScreen: legacyPermissions.allowScreenShare === false ? 'deny' : 'allow',
uploadFiles: legacyPermissions.allowFileUploads === false ? 'deny' : 'allow'
},
{
roleId: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: legacyPermissions.moderatorsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions.moderatorsManageIcon ? 'allow' : 'inherit',
kickMembers: 'allow',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
},
{
roleId: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: legacyPermissions.adminsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions.adminsManageIcon ? 'allow' : 'inherit',
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
}
];
}
function roleIdsForMemberRole(role: string): string[] {
if (role === 'admin') {
return [SYSTEM_ROLE_IDS.admin];
}
if (role === 'moderator') {
return [SYSTEM_ROLE_IDS.moderator];
}
return [];
}
export class NormalizeRoomAccessControl1000000000004 implements MigrationInterface {
name = 'NormalizeRoomAccessControl1000000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_roles" (
"roomId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT,
"position" INTEGER NOT NULL,
"isSystem" INTEGER NOT NULL DEFAULT 0,
"manageServer" TEXT NOT NULL DEFAULT 'inherit',
"manageRoles" TEXT NOT NULL DEFAULT 'inherit',
"manageChannels" TEXT NOT NULL DEFAULT 'inherit',
"manageIcon" TEXT NOT NULL DEFAULT 'inherit',
"kickMembers" TEXT NOT NULL DEFAULT 'inherit',
"banMembers" TEXT NOT NULL DEFAULT 'inherit',
"manageBans" TEXT NOT NULL DEFAULT 'inherit',
"deleteMessages" TEXT NOT NULL DEFAULT 'inherit',
"joinVoice" TEXT NOT NULL DEFAULT 'inherit',
"shareScreen" TEXT NOT NULL DEFAULT 'inherit',
"uploadFiles" TEXT NOT NULL DEFAULT 'inherit',
PRIMARY KEY ("roomId", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_roles_roomId" ON "room_roles" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_user_roles" (
"roomId" TEXT NOT NULL,
"userKey" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"oderId" TEXT,
PRIMARY KEY ("roomId", "userKey", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_user_roles_roomId" ON "room_user_roles" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_channel_permissions" (
"roomId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"permission" TEXT NOT NULL,
"value" TEXT NOT NULL,
PRIMARY KEY ("roomId", "channelId", "targetType", "targetId", "permission")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channel_permissions_roomId" ON "room_channel_permissions" ("roomId")`);
const rooms = await queryRunner.query(`
SELECT "id", "name", "description", "topic", "hostId", "password", "hasPassword", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "sourceId", "sourceName", "sourceUrl"
FROM "rooms"
`) as LegacyRoomRow[];
const members = await queryRunner.query(`
SELECT "roomId", "memberKey", "id", "oderId", "role"
FROM "room_members"
`) as RoomMemberRow[];
for (const room of rooms) {
const legacyPermissions = parseLegacyPermissions(room.permissions);
const roles = buildDefaultRoomRoles(legacyPermissions);
for (const role of roles) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_roles" ("roomId", "roleId", "name", "color", "position", "isSystem", "manageServer", "manageRoles", "manageChannels", "manageIcon", "kickMembers", "banMembers", "manageBans", "deleteMessages", "joinVoice", "shareScreen", "uploadFiles") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id,
role.roleId,
role.name,
role.color,
role.position,
role.isSystem,
role.manageServer,
role.manageRoles,
role.manageChannels,
role.manageIcon,
role.kickMembers,
role.banMembers,
role.manageBans,
role.deleteMessages,
role.joinVoice,
role.shareScreen,
role.uploadFiles
]
);
}
for (const member of members.filter((candidateMember) => candidateMember.roomId === room.id)) {
for (const roleId of roleIdsForMemberRole(member.role)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_user_roles" ("roomId", "userKey", "roleId", "userId", "oderId") VALUES (?, ?, ?, ?, ?)`,
[
room.id,
member.memberKey,
roleId,
member.id,
member.oderId
]
);
}
}
}
await queryRunner.query(`
CREATE TABLE "rooms_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"topic" TEXT,
"hostId" TEXT NOT NULL,
"password" TEXT,
"hasPassword" INTEGER NOT NULL DEFAULT 0,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"userCount" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER,
"icon" TEXT,
"iconUpdatedAt" INTEGER,
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
"sourceId" TEXT,
"sourceName" TEXT,
"sourceUrl" TEXT
)
`);
for (const room of rooms) {
const legacyPermissions = parseLegacyPermissions(room.permissions);
await queryRunner.query(
`INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "hasPassword", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "slowModeInterval", "sourceId", "sourceName", "sourceUrl") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id,
room.name,
room.description,
room.topic,
room.hostId,
room.password,
room.hasPassword,
room.isPrivate,
room.createdAt,
room.userCount,
room.maxUsers,
room.icon,
room.iconUpdatedAt,
legacyPermissions.slowModeInterval ?? 0,
room.sourceId,
room.sourceName,
room.sourceUrl
]
);
}
await queryRunner.query(`DROP TABLE "rooms"`);
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "room_channel_permissions"`);
await queryRunner.query(`DROP TABLE IF EXISTS "room_user_roles"`);
await queryRunner.query(`DROP TABLE IF EXISTS "room_roles"`);
}
}

View File

@@ -48,7 +48,6 @@ export async function listSavedThemes(): Promise<SavedThemeFileDescriptor[]> {
const themesPath = await ensureSavedThemesPath(); const themesPath = await ensureSavedThemesPath();
const entries = await fsp.readdir(themesPath, { withFileTypes: true }); const entries = await fsp.readdir(themesPath, { withFileTypes: true });
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json')); const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'));
const descriptors = await Promise.all(files.map(async (entry) => { const descriptors = await Promise.all(files.map(async (entry) => {
const filePath = path.join(themesPath, entry.name); const filePath = path.join(themesPath, entry.name);
const stats = await fsp.stat(filePath); const stats = await fsp.stat(filePath);
@@ -89,4 +88,4 @@ export async function deleteSavedTheme(fileName: string): Promise<boolean> {
throw error; throw error;
} }
} }

View File

@@ -1,8 +1,11 @@
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { import {
ServerChannelPermissionEntity,
ServerChannelEntity, ServerChannelEntity,
ServerEntity, ServerEntity,
ServerRoleEntity,
ServerTagEntity, ServerTagEntity,
ServerUserRoleEntity,
JoinRequestEntity, JoinRequestEntity,
ServerMembershipEntity, ServerMembershipEntity,
ServerInviteEntity, ServerInviteEntity,
@@ -16,6 +19,9 @@ export async function handleDeleteServer(command: DeleteServerCommand, dataSourc
await dataSource.transaction(async (manager) => { await dataSource.transaction(async (manager) => {
await manager.getRepository(ServerTagEntity).delete({ serverId }); await manager.getRepository(ServerTagEntity).delete({ serverId });
await manager.getRepository(ServerChannelEntity).delete({ serverId }); await manager.getRepository(ServerChannelEntity).delete({ serverId });
await manager.getRepository(ServerRoleEntity).delete({ serverId });
await manager.getRepository(ServerUserRoleEntity).delete({ serverId });
await manager.getRepository(ServerChannelPermissionEntity).delete({ serverId });
await manager.getRepository(JoinRequestEntity).delete({ serverId }); await manager.getRepository(JoinRequestEntity).delete({ serverId });
await manager.getRepository(ServerMembershipEntity).delete({ serverId }); await manager.getRepository(ServerMembershipEntity).delete({ serverId });
await manager.getRepository(ServerInviteEntity).delete({ serverId }); await manager.getRepository(ServerInviteEntity).delete({ serverId });

View File

@@ -5,6 +5,7 @@ import { UpsertServerCommand } from '../../types';
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> { export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
const { server } = command.payload; const { server } = command.payload;
await dataSource.transaction(async (manager) => { await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(ServerEntity); const repo = manager.getRepository(ServerEntity);
const entity = repo.create({ const entity = repo.create({
@@ -17,6 +18,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
isPrivate: server.isPrivate ? 1 : 0, isPrivate: server.isPrivate ? 1 : 0,
maxUsers: server.maxUsers, maxUsers: server.maxUsers,
currentUsers: server.currentUsers, currentUsers: server.currentUsers,
slowModeInterval: server.slowModeInterval ?? 0,
createdAt: server.createdAt, createdAt: server.createdAt,
lastSeen: server.lastSeen lastSeen: server.lastSeen
}); });
@@ -24,7 +26,10 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
await repo.save(entity); await repo.save(entity);
await replaceServerRelations(manager, server.id, { await replaceServerRelations(manager, server.id, {
tags: server.tags, tags: server.tags,
channels: server.channels ?? [] channels: server.channels ?? [],
roles: server.roles ?? [],
roleAssignments: server.roleAssignments ?? [],
channelPermissions: server.channelPermissions ?? []
}); });
}); });
} }

View File

@@ -6,6 +6,7 @@ import {
ServerPayload, ServerPayload,
JoinRequestPayload JoinRequestPayload
} from './types'; } from './types';
import { relationRecordToServerPayload } from './relations';
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload { export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
return { return {
@@ -19,8 +20,22 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
export function rowToServer( export function rowToServer(
row: ServerEntity, row: ServerEntity,
relations: Pick<ServerPayload, 'tags' | 'channels'> = { tags: [], channels: [] } relations: Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions'> = {
tags: [],
channels: [],
roles: [],
roleAssignments: [],
channelPermissions: []
}
): ServerPayload { ): ServerPayload {
const relationPayload = relationRecordToServerPayload({ slowModeInterval: row.slowModeInterval }, {
tags: relations.tags ?? [],
channels: relations.channels ?? [],
roles: relations.roles ?? [],
roleAssignments: relations.roleAssignments ?? [],
channelPermissions: relations.channelPermissions ?? []
});
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
@@ -32,8 +47,12 @@ export function rowToServer(
isPrivate: !!row.isPrivate, isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers, maxUsers: row.maxUsers,
currentUsers: row.currentUsers, currentUsers: row.currentUsers,
tags: relations.tags ?? [], slowModeInterval: relationPayload.slowModeInterval,
channels: relations.channels ?? [], tags: relationPayload.tags,
channels: relationPayload.channels,
roles: relationPayload.roles,
roleAssignments: relationPayload.roleAssignments,
channelPermissions: relationPayload.channelPermissions,
createdAt: row.createdAt, createdAt: row.createdAt,
lastSeen: row.lastSeen lastSeen: row.lastSeen
}; };

View File

@@ -5,13 +5,46 @@ import {
} from 'typeorm'; } from 'typeorm';
import { import {
ServerChannelEntity, ServerChannelEntity,
ServerTagEntity ServerTagEntity,
ServerRoleEntity,
ServerUserRoleEntity,
ServerChannelPermissionEntity
} from '../entities'; } from '../entities';
import { ServerChannelPayload } from './types'; import {
AccessRolePayload,
ChannelPermissionPayload,
RoleAssignmentPayload,
ServerChannelPayload,
ServerPayload,
ServerPermissionKeyPayload,
PermissionStatePayload
} from './types';
const SERVER_PERMISSION_KEYS: ServerPermissionKeyPayload[] = [
'manageServer',
'manageRoles',
'manageChannels',
'manageIcon',
'kickMembers',
'banMembers',
'manageBans',
'deleteMessages',
'joinVoice',
'shareScreen',
'uploadFiles'
];
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
interface ServerRelationRecord { interface ServerRelationRecord {
tags: string[]; tags: string[];
channels: ServerChannelPayload[]; channels: ServerChannelPayload[];
roles: AccessRolePayload[];
roleAssignments: RoleAssignmentPayload[];
channelPermissions: ChannelPermissionPayload[];
} }
function normalizeChannelName(name: string): string { function normalizeChannelName(name: string): string {
@@ -22,16 +55,125 @@ function channelNameKey(type: ServerChannelPayload['type'], name: string): strin
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`; return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
} }
function compareText(firstValue: string, secondValue: string): number {
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
}
function isFiniteNumber(value: unknown): value is number { function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value); return typeof value === 'number' && Number.isFinite(value);
} }
function uniqueStrings(values: readonly string[] | undefined): string[] {
return Array.from(new Set((values ?? [])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())));
}
function normalizePermissionState(value: unknown): PermissionStatePayload {
return value === 'allow' || value === 'deny' || value === 'inherit'
? value
: 'inherit';
}
function normalizePermissionMatrix(rawMatrix: unknown): Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> {
const matrix = rawMatrix && typeof rawMatrix === 'object'
? rawMatrix as Record<string, unknown>
: {};
const normalized: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> = {};
for (const key of SERVER_PERMISSION_KEYS) {
const value = normalizePermissionState(matrix[key]);
if (value !== 'inherit') {
normalized[key] = value;
}
}
return normalized;
}
function buildDefaultServerRoles(): AccessRolePayload[] {
return [
{
id: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: true,
permissions: {
joinVoice: 'allow',
shareScreen: 'allow',
uploadFiles: 'allow'
}
},
{
id: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: true,
permissions: {
kickMembers: 'allow',
deleteMessages: 'allow'
}
},
{
id: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: true,
permissions: {
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
manageChannels: 'allow',
manageIcon: 'allow'
}
}
];
}
function normalizeServerRole(rawRole: Partial<AccessRolePayload>, fallbackRole?: AccessRolePayload): AccessRolePayload | null {
const id = typeof rawRole.id === 'string' ? rawRole.id.trim() : fallbackRole?.id ?? '';
const name = typeof rawRole.name === 'string' ? rawRole.name.trim().replace(/\s+/g, ' ') : fallbackRole?.name ?? '';
if (!id || !name) {
return null;
}
return {
id,
name,
color: typeof rawRole.color === 'string' && rawRole.color.trim() ? rawRole.color.trim() : fallbackRole?.color,
position: isFiniteNumber(rawRole.position) ? rawRole.position : fallbackRole?.position ?? 0,
isSystem: typeof rawRole.isSystem === 'boolean' ? rawRole.isSystem : fallbackRole?.isSystem,
permissions: normalizePermissionMatrix(rawRole.permissions ?? fallbackRole?.permissions)
};
}
function compareRoles(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
if (firstRole.position !== secondRole.position) {
return firstRole.position - secondRole.position;
}
return compareText(firstRole.name, secondRole.name);
}
function compareAssignments(firstAssignment: RoleAssignmentPayload, secondAssignment: RoleAssignmentPayload): number {
return compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId);
}
export function normalizeServerTags(rawTags: unknown): string[] { export function normalizeServerTags(rawTags: unknown): string[] {
if (!Array.isArray(rawTags)) { if (!Array.isArray(rawTags)) {
return []; return [];
} }
return rawTags.filter((tag): tag is string => typeof tag === 'string'); return rawTags
.filter((tag): tag is string => typeof tag === 'string')
.map((tag) => tag.trim())
.filter(Boolean);
} }
export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayload[] { export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayload[] {
@@ -72,19 +214,169 @@ export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayl
return channels; return channels;
} }
export function normalizeServerRoles(rawRoles: unknown): AccessRolePayload[] {
const rolesById = new Map<string, AccessRolePayload>();
if (Array.isArray(rawRoles)) {
for (const rawRole of rawRoles) {
if (!rawRole || typeof rawRole !== 'object') {
continue;
}
const normalizedRole = normalizeServerRole(rawRole as Record<string, unknown>);
if (normalizedRole) {
rolesById.set(normalizedRole.id, normalizedRole);
}
}
}
for (const defaultRole of buildDefaultServerRoles()) {
const mergedRole = normalizeServerRole(rolesById.get(defaultRole.id) ?? defaultRole, defaultRole) ?? defaultRole;
rolesById.set(defaultRole.id, mergedRole);
}
return Array.from(rolesById.values()).sort(compareRoles);
}
export function normalizeServerRoleAssignments(rawAssignments: unknown, roles: readonly AccessRolePayload[]): RoleAssignmentPayload[] {
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
const assignmentsByKey = new Map<string, RoleAssignmentPayload>();
if (!Array.isArray(rawAssignments)) {
return [];
}
for (const rawAssignment of rawAssignments) {
if (!rawAssignment || typeof rawAssignment !== 'object') {
continue;
}
const assignment = rawAssignment as Record<string, unknown>;
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(Array.isArray(assignment['roleIds']) ? assignment['roleIds'] as string[] : undefined)
.filter((roleId) => validRoleIds.has(roleId));
if (roleIds.length === 0) {
continue;
}
assignmentsByKey.set(key, {
userId: userId || key,
oderId,
roleIds
});
}
return Array.from(assignmentsByKey.values()).sort(compareAssignments);
}
export function normalizeServerChannelPermissions(
rawChannelPermissions: unknown,
roles: readonly AccessRolePayload[]
): ChannelPermissionPayload[] {
if (!Array.isArray(rawChannelPermissions)) {
return [];
}
const validRoleIds = new Set(roles.map((role) => role.id));
const overridesByKey = new Map<string, ChannelPermissionPayload>();
for (const rawOverride of rawChannelPermissions) {
if (!rawOverride || typeof rawOverride !== 'object') {
continue;
}
const override = rawOverride as Record<string, unknown>;
const channelId = typeof override['channelId'] === 'string' ? override['channelId'].trim() : '';
const targetType = override['targetType'] === 'role' || override['targetType'] === 'user' ? override['targetType'] : null;
const targetId = typeof override['targetId'] === 'string' ? override['targetId'].trim() : '';
const permission = SERVER_PERMISSION_KEYS.find((key) => key === override['permission']);
const value = normalizePermissionState(override['value']);
if (!channelId || !targetType || !targetId || !permission || value === 'inherit') {
continue;
}
if (targetType === 'role' && !validRoleIds.has(targetId)) {
continue;
}
const key = `${channelId}:${targetType}:${targetId}:${permission}`;
overridesByKey.set(key, {
channelId,
targetType,
targetId,
permission,
value
});
}
return Array.from(overridesByKey.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 async function replaceServerRelations( export async function replaceServerRelations(
manager: EntityManager, manager: EntityManager,
serverId: string, serverId: string,
options: { tags: unknown; channels: unknown } options: {
tags: unknown;
channels: unknown;
roles?: unknown;
roleAssignments?: unknown;
channelPermissions?: unknown;
}
): Promise<void> { ): Promise<void> {
const tagRepo = manager.getRepository(ServerTagEntity); const tagRepo = manager.getRepository(ServerTagEntity);
const channelRepo = manager.getRepository(ServerChannelEntity); const channelRepo = manager.getRepository(ServerChannelEntity);
const roleRepo = manager.getRepository(ServerRoleEntity);
const userRoleRepo = manager.getRepository(ServerUserRoleEntity);
const channelPermissionRepo = manager.getRepository(ServerChannelPermissionEntity);
const tags = normalizeServerTags(options.tags); const tags = normalizeServerTags(options.tags);
const channels = normalizeServerChannels(options.channels); const channels = normalizeServerChannels(options.channels);
const roles = options.roles !== undefined ? normalizeServerRoles(options.roles) : [];
await tagRepo.delete({ serverId }); await tagRepo.delete({ serverId });
await channelRepo.delete({ serverId }); await channelRepo.delete({ serverId });
if (options.roles !== undefined) {
await roleRepo.delete({ serverId });
}
if (options.roleAssignments !== undefined) {
await userRoleRepo.delete({ serverId });
}
if (options.channelPermissions !== undefined) {
await channelPermissionRepo.delete({ serverId });
}
if (tags.length > 0) { if (tags.length > 0) {
await tagRepo.insert( await tagRepo.insert(
tags.map((tag, position) => ({ tags.map((tag, position) => ({
@@ -106,6 +398,66 @@ export async function replaceServerRelations(
})) }))
); );
} }
if (options.roles !== undefined && roles.length > 0) {
await roleRepo.insert(
roles.map((role) => ({
serverId,
roleId: role.id,
name: role.name,
color: role.color ?? null,
position: role.position,
isSystem: role.isSystem ? 1 : 0,
manageServer: normalizePermissionState(role.permissions?.manageServer),
manageRoles: normalizePermissionState(role.permissions?.manageRoles),
manageChannels: normalizePermissionState(role.permissions?.manageChannels),
manageIcon: normalizePermissionState(role.permissions?.manageIcon),
kickMembers: normalizePermissionState(role.permissions?.kickMembers),
banMembers: normalizePermissionState(role.permissions?.banMembers),
manageBans: normalizePermissionState(role.permissions?.manageBans),
deleteMessages: normalizePermissionState(role.permissions?.deleteMessages),
joinVoice: normalizePermissionState(role.permissions?.joinVoice),
shareScreen: normalizePermissionState(role.permissions?.shareScreen),
uploadFiles: normalizePermissionState(role.permissions?.uploadFiles)
}))
);
}
if (options.roleAssignments !== undefined) {
const roleAssignments = normalizeServerRoleAssignments(options.roleAssignments, roles.length > 0 ? roles : normalizeServerRoles([]));
const rows = roleAssignments.flatMap((assignment) =>
assignment.roleIds.map((roleId) => ({
serverId,
userId: assignment.userId,
roleId,
oderId: assignment.oderId ?? null
}))
);
if (rows.length > 0) {
await userRoleRepo.insert(rows);
}
}
if (options.channelPermissions !== undefined) {
const channelPermissions = normalizeServerChannelPermissions(
options.channelPermissions,
roles.length > 0 ? roles : normalizeServerRoles([])
);
if (channelPermissions.length > 0) {
await channelPermissionRepo.insert(
channelPermissions.map((channelPermission) => ({
serverId,
channelId: channelPermission.channelId,
targetType: channelPermission.targetType,
targetId: channelPermission.targetId,
permission: channelPermission.permission,
value: channelPermission.value
}))
);
}
}
} }
export async function loadServerRelationsMap( export async function loadServerRelationsMap(
@@ -118,43 +470,134 @@ export async function loadServerRelationsMap(
return groupedRelations; return groupedRelations;
} }
const [tagRows, channelRows] = await Promise.all([ const [
tagRows,
channelRows,
roleRows,
userRoleRows,
channelPermissionRows
] = await Promise.all([
dataSource.getRepository(ServerTagEntity).find({ dataSource.getRepository(ServerTagEntity).find({
where: { serverId: In([...serverIds]) } where: { serverId: In([...serverIds]) }
}), }),
dataSource.getRepository(ServerChannelEntity).find({ dataSource.getRepository(ServerChannelEntity).find({
where: { serverId: In([...serverIds]) } where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerRoleEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerUserRoleEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerChannelPermissionEntity).find({
where: { serverId: In([...serverIds]) }
}) })
]); ]);
for (const row of tagRows) { for (const serverId of serverIds) {
const relation = groupedRelations.get(row.serverId) ?? { tags: [], channels: [] }; groupedRelations.set(serverId, {
tags: [],
channels: [],
roles: [],
roleAssignments: [],
channelPermissions: []
});
}
relation.tags.push(row.value); for (const row of tagRows) {
groupedRelations.set(row.serverId, relation); groupedRelations.get(row.serverId)?.tags.push(row.value);
} }
for (const row of channelRows) { for (const row of channelRows) {
const relation = groupedRelations.get(row.serverId) ?? { tags: [], channels: [] }; groupedRelations.get(row.serverId)?.channels.push({
relation.channels.push({
id: row.channelId, id: row.channelId,
name: row.name, name: row.name,
type: row.type, type: row.type,
position: row.position position: row.position
}); });
groupedRelations.set(row.serverId, relation); }
for (const row of roleRows) {
groupedRelations.get(row.serverId)?.roles.push({
id: row.roleId,
name: row.name,
color: row.color ?? undefined,
position: row.position,
isSystem: !!row.isSystem,
permissions: normalizePermissionMatrix({
manageServer: row.manageServer,
manageRoles: row.manageRoles,
manageChannels: row.manageChannels,
manageIcon: row.manageIcon,
kickMembers: row.kickMembers,
banMembers: row.banMembers,
manageBans: row.manageBans,
deleteMessages: row.deleteMessages,
joinVoice: row.joinVoice,
shareScreen: row.shareScreen,
uploadFiles: row.uploadFiles
})
});
}
for (const row of userRoleRows) {
const relation = groupedRelations.get(row.serverId);
if (!relation) {
continue;
}
const existing = relation.roleAssignments.find((assignment) => assignment.userId === row.userId || assignment.oderId === row.oderId);
if (existing) {
existing.roleIds = uniqueStrings([...existing.roleIds, row.roleId]);
continue;
}
relation.roleAssignments.push({
userId: row.userId,
oderId: row.oderId ?? undefined,
roleIds: [row.roleId]
});
}
for (const row of channelPermissionRows) {
groupedRelations.get(row.serverId)?.channelPermissions.push({
channelId: row.channelId,
targetType: row.targetType,
targetId: row.targetId,
permission: row.permission as ServerPermissionKeyPayload,
value: normalizePermissionState(row.value)
});
} }
for (const [serverId, relation] of groupedRelations) { for (const [serverId, relation] of groupedRelations) {
const orderedTags = tagRows relation.tags = tagRows
.filter((row) => row.serverId === serverId) .filter((row) => row.serverId === serverId)
.sort((first, second) => first.position - second.position) .sort((firstTag, secondTag) => firstTag.position - secondTag.position)
.map((row) => row.value); .map((row) => row.value);
relation.tags = orderedTags; relation.channels.sort(
relation.channels.sort((first, second) => first.position - second.position || first.name.localeCompare(second.name)); (firstChannel, secondChannel) => firstChannel.position - secondChannel.position || compareText(firstChannel.name, secondChannel.name)
);
relation.roles.sort(compareRoles);
relation.roleAssignments.sort(compareAssignments);
} }
return groupedRelations; return groupedRelations;
} }
export function relationRecordToServerPayload(
row: Pick<ServerPayload, 'slowModeInterval'>,
relations: ServerRelationRecord
): Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions' | 'slowModeInterval'> {
return {
tags: relations.tags,
channels: relations.channels,
roles: relations.roles,
roleAssignments: relations.roleAssignments,
channelPermissions: relations.channelPermissions,
slowModeInterval: row.slowModeInterval ?? 0
};
}

View File

@@ -37,6 +37,44 @@ export interface ServerChannelPayload {
position: number; position: number;
} }
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
export type ServerPermissionKeyPayload =
| 'manageServer'
| 'manageRoles'
| 'manageChannels'
| 'manageIcon'
| 'kickMembers'
| 'banMembers'
| 'manageBans'
| 'deleteMessages'
| 'joinVoice'
| 'shareScreen'
| 'uploadFiles';
export interface AccessRolePayload {
id: string;
name: string;
color?: string;
position: number;
isSystem?: boolean;
permissions?: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>>;
}
export interface RoleAssignmentPayload {
userId: string;
oderId?: string;
roleIds: string[];
}
export interface ChannelPermissionPayload {
channelId: string;
targetType: 'role' | 'user';
targetId: string;
permission: ServerPermissionKeyPayload;
value: PermissionStatePayload;
}
export interface ServerPayload { export interface ServerPayload {
id: string; id: string;
name: string; name: string;
@@ -48,8 +86,12 @@ export interface ServerPayload {
isPrivate: boolean; isPrivate: boolean;
maxUsers: number; maxUsers: number;
currentUsers: number; currentUsers: number;
slowModeInterval?: number;
tags: string[]; tags: string[];
channels: ServerChannelPayload[]; channels: ServerChannelPayload[];
roles?: AccessRolePayload[];
roleAssignments?: RoleAssignmentPayload[];
channelPermissions?: ChannelPermissionPayload[];
createdAt: number; createdAt: number;
lastSeen: number; lastSeen: number;
} }

View File

@@ -6,6 +6,9 @@ import {
ServerEntity, ServerEntity,
ServerTagEntity, ServerTagEntity,
ServerChannelEntity, ServerChannelEntity,
ServerRoleEntity,
ServerUserRoleEntity,
ServerChannelPermissionEntity,
JoinRequestEntity, JoinRequestEntity,
ServerMembershipEntity, ServerMembershipEntity,
ServerInviteEntity, ServerInviteEntity,
@@ -58,6 +61,9 @@ export async function initDatabase(): Promise<void> {
ServerEntity, ServerEntity,
ServerTagEntity, ServerTagEntity,
ServerChannelEntity, ServerChannelEntity,
ServerRoleEntity,
ServerUserRoleEntity,
ServerChannelPermissionEntity,
JoinRequestEntity, JoinRequestEntity,
ServerMembershipEntity, ServerMembershipEntity,
ServerInviteEntity, ServerInviteEntity,

View File

@@ -20,4 +20,4 @@ export class ServerChannelEntity {
@Column('integer') @Column('integer')
position!: number; position!: number;
} }

View File

@@ -0,0 +1,26 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('server_channel_permissions')
export class ServerChannelPermissionEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
channelId!: string;
@PrimaryColumn('text')
targetType!: 'role' | 'user';
@PrimaryColumn('text')
targetId!: string;
@PrimaryColumn('text')
permission!: string;
@Column('text')
value!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -33,6 +33,9 @@ export class ServerEntity {
@Column('integer', { default: 0 }) @Column('integer', { default: 0 })
currentUsers!: number; currentUsers!: number;
@Column('integer', { default: 0 })
slowModeInterval!: number;
@Column('integer') @Column('integer')
createdAt!: number; createdAt!: number;

View File

@@ -0,0 +1,59 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('server_roles')
export class ServerRoleEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text')
name!: string;
@Column('text', { nullable: true })
color!: string | null;
@Column('integer')
position!: number;
@Column('integer', { default: 0 })
isSystem!: number;
@Column('text', { default: 'inherit' })
manageServer!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageRoles!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageChannels!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageIcon!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
kickMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
banMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageBans!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
deleteMessages!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
joinVoice!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
shareScreen!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
uploadFiles!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -14,4 +14,4 @@ export class ServerTagEntity {
@Column('text') @Column('text')
value!: string; value!: string;
} }

View File

@@ -0,0 +1,20 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('server_user_roles')
export class ServerUserRoleEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
userId!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text', { nullable: true })
oderId!: string | null;
}

View File

@@ -2,6 +2,9 @@ export { AuthUserEntity } from './AuthUserEntity';
export { ServerEntity } from './ServerEntity'; export { ServerEntity } from './ServerEntity';
export { ServerTagEntity } from './ServerTagEntity'; export { ServerTagEntity } from './ServerTagEntity';
export { ServerChannelEntity } from './ServerChannelEntity'; export { ServerChannelEntity } from './ServerChannelEntity';
export { ServerRoleEntity } from './ServerRoleEntity';
export { ServerUserRoleEntity } from './ServerUserRoleEntity';
export { ServerChannelPermissionEntity } from './ServerChannelPermissionEntity';
export { JoinRequestEntity } from './JoinRequestEntity'; export { JoinRequestEntity } from './JoinRequestEntity';
export { ServerMembershipEntity } from './ServerMembershipEntity'; export { ServerMembershipEntity } from './ServerMembershipEntity';
export { ServerInviteEntity } from './ServerInviteEntity'; export { ServerInviteEntity } from './ServerInviteEntity';

View File

@@ -139,4 +139,4 @@ export class NormalizeServerArrays1000000000004 implements MigrationInterface {
await queryRunner.query(`DROP TABLE IF EXISTS "server_channels"`); await queryRunner.query(`DROP TABLE IF EXISTS "server_channels"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_tags"`); await queryRunner.query(`DROP TABLE IF EXISTS "server_tags"`);
} }
} }

View File

@@ -0,0 +1,196 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyServerRow = {
id: string;
name: string;
description: string | null;
ownerId: string;
ownerPublicKey: string;
passwordHash: string | null;
isPrivate: number;
maxUsers: number;
currentUsers: number;
createdAt: number;
lastSeen: number;
};
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
function buildDefaultServerRoles() {
return [
{
roleId: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'inherit',
manageIcon: 'inherit',
kickMembers: 'inherit',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'inherit',
joinVoice: 'allow',
shareScreen: 'allow',
uploadFiles: 'allow'
},
{
roleId: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'inherit',
manageIcon: 'inherit',
kickMembers: 'allow',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
},
{
roleId: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'allow',
manageIcon: 'allow',
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
}
];
}
export class ServerRoleAccessControl1000000000005 implements MigrationInterface {
name = 'ServerRoleAccessControl1000000000005';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_roles" (
"serverId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT,
"position" INTEGER NOT NULL,
"isSystem" INTEGER NOT NULL DEFAULT 0,
"manageServer" TEXT NOT NULL DEFAULT 'inherit',
"manageRoles" TEXT NOT NULL DEFAULT 'inherit',
"manageChannels" TEXT NOT NULL DEFAULT 'inherit',
"manageIcon" TEXT NOT NULL DEFAULT 'inherit',
"kickMembers" TEXT NOT NULL DEFAULT 'inherit',
"banMembers" TEXT NOT NULL DEFAULT 'inherit',
"manageBans" TEXT NOT NULL DEFAULT 'inherit',
"deleteMessages" TEXT NOT NULL DEFAULT 'inherit',
"joinVoice" TEXT NOT NULL DEFAULT 'inherit',
"shareScreen" TEXT NOT NULL DEFAULT 'inherit',
"uploadFiles" TEXT NOT NULL DEFAULT 'inherit',
PRIMARY KEY ("serverId", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_roles_serverId" ON "server_roles" ("serverId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_user_roles" (
"serverId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"oderId" TEXT,
PRIMARY KEY ("serverId", "userId", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_user_roles_serverId" ON "server_user_roles" ("serverId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_channel_permissions" (
"serverId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"permission" TEXT NOT NULL,
"value" TEXT NOT NULL,
PRIMARY KEY ("serverId", "channelId", "targetType", "targetId", "permission")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channel_permissions_serverId" ON "server_channel_permissions" ("serverId")`);
const servers = await queryRunner.query(`
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen"
FROM "servers"
`) as LegacyServerRow[];
for (const server of servers) {
for (const role of buildDefaultServerRoles()) {
await queryRunner.query(
`INSERT OR REPLACE INTO "server_roles" ("serverId", "roleId", "name", "color", "position", "isSystem", "manageServer", "manageRoles", "manageChannels", "manageIcon", "kickMembers", "banMembers", "manageBans", "deleteMessages", "joinVoice", "shareScreen", "uploadFiles") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
server.id,
role.roleId,
role.name,
role.color,
role.position,
role.isSystem,
role.manageServer,
role.manageRoles,
role.manageChannels,
role.manageIcon,
role.kickMembers,
role.banMembers,
role.manageBans,
role.deleteMessages,
role.joinVoice,
role.shareScreen,
role.uploadFiles
]
);
}
}
await queryRunner.query(`
CREATE TABLE "servers_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"ownerId" TEXT NOT NULL,
"ownerPublicKey" TEXT NOT NULL,
"passwordHash" TEXT,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER NOT NULL DEFAULT 0,
"currentUsers" INTEGER NOT NULL DEFAULT 0,
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL
)
`);
await queryRunner.query(`
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "slowModeInterval", "createdAt", "lastSeen")
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", 0, "createdAt", "lastSeen"
FROM "servers"
`);
await queryRunner.query(`DROP TABLE "servers"`);
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "server_channel_permissions"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_user_roles"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_roles"`);
}
}

View File

@@ -3,11 +3,13 @@ import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessCo
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels'; import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels'; import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays'; import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
export const serverMigrations = [ export const serverMigrations = [
InitialSchema1000000000000, InitialSchema1000000000000,
ServerAccessControl1000000000001, ServerAccessControl1000000000001,
ServerChannels1000000000002, ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003, RepairLegacyVoiceChannels1000000000003,
NormalizeServerArrays1000000000004 NormalizeServerArrays1000000000004,
ServerRoleAccessControl1000000000005
]; ];

View File

@@ -6,6 +6,7 @@ import {
updateJoinRequestStatus updateJoinRequestStatus
} from '../cqrs'; } from '../cqrs';
import { notifyUser } from '../websocket/broadcast'; import { notifyUser } from '../websocket/broadcast';
import { resolveServerPermission } from '../services/server-permissions.service';
const router = Router(); const router = Router();
@@ -19,7 +20,7 @@ router.put('/:id', async (req, res) => {
const server = await getServerById(request.serverId); const server = await getServerById(request.serverId);
if (!server || server.ownerId !== ownerId) if (!server || !ownerId || !resolveServerPermission(server, String(ownerId), 'manageServer'))
return res.status(403).json({ error: 'Not authorized' }); return res.status(403).json({ error: 'Not authorized' });
await updateJoinRequestStatus(id, status as JoinRequestPayload['status']); await updateJoinRequestStatus(id, status as JoinRequestPayload['status']);

View File

@@ -1,9 +1,6 @@
import { Response, Router } from 'express'; import { Response, Router } from 'express';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { import { ServerChannelPayload, ServerPayload } from '../cqrs/types';
ServerChannelPayload,
ServerPayload
} from '../cqrs/types';
import { import {
getAllPublicServers, getAllPublicServers,
getServerById, getServerById,
@@ -30,21 +27,18 @@ import {
buildInviteUrl, buildInviteUrl,
getRequestOrigin getRequestOrigin
} from './invite-utils'; } from './invite-utils';
import {
canManageServerUpdate,
canModerateServerMember,
resolveServerPermission
} from '../services/server-permissions.service';
const router = Router(); const router = Router();
function normalizeRole(role: unknown): string | null {
return typeof role === 'string' ? role.trim().toLowerCase() : null;
}
function channelNameKey(type: ServerChannelPayload['type'], name: string): string { function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${name.toLocaleLowerCase()}`; return `${type}:${name.toLocaleLowerCase()}`;
} }
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
return !!role && allowedRoles.includes(role);
}
function normalizeServerChannels(value: unknown): ServerChannelPayload[] { function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return []; return [];
@@ -212,15 +206,20 @@ router.put('/:id', async (req, res) => {
} = req.body; } = req.body;
const existing = await getServerById(id); const existing = await getServerById(id);
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId; const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
const normalizedRole = normalizeRole(actingRole);
if (!existing) if (!existing)
return res.status(404).json({ error: 'Server not found' }); return res.status(404).json({ error: 'Server not found' });
if ( if (!authenticatedOwnerId) {
existing.ownerId !== authenticatedOwnerId && return res.status(400).json({ error: 'Missing currentOwnerId' });
!isAllowedRole(normalizedRole, ['host', 'admin']) }
) {
if (!canManageServerUpdate(existing, authenticatedOwnerId, {
...updates,
channels,
password,
actingRole
})) {
return res.status(403).json({ error: 'Not authorized' }); return res.status(403).json({ error: 'Not authorized' });
} }
@@ -298,7 +297,7 @@ router.post('/:id/invites', async (req, res) => {
router.post('/:id/moderation/kick', async (req, res) => { router.post('/:id/moderation/kick', async (req, res) => {
const { id: serverId } = req.params; const { id: serverId } = req.params;
const { actorUserId, actorRole, targetUserId } = req.body; const { actorUserId, targetUserId } = req.body;
const server = await getServerById(serverId); const server = await getServerById(serverId);
if (!server) { if (!server) {
@@ -309,14 +308,7 @@ router.post('/:id/moderation/kick', async (req, res) => {
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' }); return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
} }
if ( if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) {
server.ownerId !== actorUserId &&
!isAllowedRole(normalizeRole(actorRole), [
'host',
'admin',
'moderator'
])
) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' }); return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
} }
@@ -327,7 +319,7 @@ router.post('/:id/moderation/kick', async (req, res) => {
router.post('/:id/moderation/ban', async (req, res) => { router.post('/:id/moderation/ban', async (req, res) => {
const { id: serverId } = req.params; const { id: serverId } = req.params;
const { actorUserId, actorRole, targetUserId, banId, displayName, reason, expiresAt } = req.body; const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body;
const server = await getServerById(serverId); const server = await getServerById(serverId);
if (!server) { if (!server) {
@@ -338,14 +330,7 @@ router.post('/:id/moderation/ban', async (req, res) => {
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' }); return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
} }
if ( if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) {
server.ownerId !== actorUserId &&
!isAllowedRole(normalizeRole(actorRole), [
'host',
'admin',
'moderator'
])
) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' }); return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
} }
@@ -364,21 +349,14 @@ router.post('/:id/moderation/ban', async (req, res) => {
router.post('/:id/moderation/unban', async (req, res) => { router.post('/:id/moderation/unban', async (req, res) => {
const { id: serverId } = req.params; const { id: serverId } = req.params;
const { actorUserId, actorRole, banId, targetUserId } = req.body; const { actorUserId, banId, targetUserId } = req.body;
const server = await getServerById(serverId); const server = await getServerById(serverId);
if (!server) { if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' }); return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
} }
if ( if (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) {
server.ownerId !== actorUserId &&
!isAllowedRole(normalizeRole(actorRole), [
'host',
'admin',
'moderator'
])
) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' }); return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
} }

View File

@@ -0,0 +1,191 @@
import type {
AccessRolePayload,
PermissionStatePayload,
RoleAssignmentPayload,
ServerPayload,
ServerPermissionKeyPayload
} from '../cqrs/types';
import { normalizeServerRoleAssignments, normalizeServerRoles } from '../cqrs/relations';
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone'
} as const;
interface ServerIdentity {
userId: string;
oderId?: string;
}
function getServerRoles(server: Pick<ServerPayload, 'roles'>): AccessRolePayload[] {
return normalizeServerRoles(server.roles);
}
function getServerAssignments(server: Pick<ServerPayload, 'roleAssignments' | 'roles'>): RoleAssignmentPayload[] {
return normalizeServerRoleAssignments(server.roleAssignments, getServerRoles(server));
}
function matchesIdentity(identity: ServerIdentity, assignment: RoleAssignmentPayload): boolean {
return assignment.userId === identity.userId
|| assignment.oderId === identity.userId
|| (!!identity.oderId && (assignment.userId === identity.oderId || assignment.oderId === identity.oderId));
}
function resolveAssignedRoleIds(server: Pick<ServerPayload, 'roleAssignments' | 'roles'>, identity: ServerIdentity): string[] {
const assignment = getServerAssignments(server).find((candidateAssignment) => matchesIdentity(identity, candidateAssignment));
return assignment?.roleIds ?? [];
}
function compareRolePosition(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
if (firstRole.position !== secondRole.position) {
return firstRole.position - secondRole.position;
}
return firstRole.name.localeCompare(secondRole.name, undefined, { sensitivity: 'base' });
}
function resolveRolePermissionState(
roles: readonly AccessRolePayload[],
assignedRoleIds: readonly string[],
permission: ServerPermissionKeyPayload
): PermissionStatePayload {
const roleLookup = new Map(roles.map((role) => [role.id, role]));
const effectiveRoles = [roleLookup.get(SYSTEM_ROLE_IDS.everyone), ...assignedRoleIds.map((roleId) => roleLookup.get(roleId))]
.filter((role): role is AccessRolePayload => !!role)
.sort(compareRolePosition);
let state: PermissionStatePayload = 'inherit';
for (const role of effectiveRoles) {
const nextState = role.permissions?.[permission] ?? 'inherit';
if (nextState !== 'inherit') {
state = nextState;
}
}
return state;
}
function resolveHighestRole(
server: Pick<ServerPayload, 'roleAssignments' | 'roles'>,
identity: ServerIdentity
): AccessRolePayload | null {
const roles = getServerRoles(server);
const assignedRoleIds = resolveAssignedRoleIds(server, identity);
const roleLookup = new Map(roles.map((role) => [role.id, role]));
const assignedRoles = assignedRoleIds
.map((roleId) => roleLookup.get(roleId))
.filter((role): role is AccessRolePayload => !!role)
.sort((firstRole, secondRole) => compareRolePosition(secondRole, firstRole));
return assignedRoles[0] ?? roleLookup.get(SYSTEM_ROLE_IDS.everyone) ?? null;
}
export function isServerOwner(server: Pick<ServerPayload, 'ownerId'>, actorUserId: string): boolean {
return server.ownerId === actorUserId;
}
export function resolveServerPermission(
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
actorUserId: string,
permission: ServerPermissionKeyPayload,
actorOderId?: string
): boolean {
if (isServerOwner(server, actorUserId)) {
return true;
}
const roles = getServerRoles(server);
const assignedRoleIds = resolveAssignedRoleIds(server, {
userId: actorUserId,
oderId: actorOderId
});
return resolveRolePermissionState(roles, assignedRoleIds, permission) === 'allow';
}
export function canManageServerUpdate(
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
actorUserId: string,
updates: Record<string, unknown>,
actorOderId?: string
): boolean {
if (isServerOwner(server, actorUserId)) {
return true;
}
if (typeof updates['ownerId'] === 'string' || typeof updates['ownerPublicKey'] === 'string') {
return false;
}
const requiredPermissions = new Set<ServerPermissionKeyPayload>();
if (
Array.isArray(updates['roles'])
|| Array.isArray(updates['roleAssignments'])
|| Array.isArray(updates['channelPermissions'])
) {
requiredPermissions.add('manageRoles');
}
if (Array.isArray(updates['channels'])) {
requiredPermissions.add('manageChannels');
}
if (typeof updates['icon'] === 'string') {
requiredPermissions.add('manageIcon');
}
if (
typeof updates['name'] === 'string'
|| typeof updates['description'] === 'string'
|| typeof updates['isPrivate'] === 'boolean'
|| typeof updates['maxUsers'] === 'number'
|| typeof updates['password'] === 'string'
|| typeof updates['passwordHash'] === 'string'
|| typeof updates['slowModeInterval'] === 'number'
) {
requiredPermissions.add('manageServer');
}
return Array.from(requiredPermissions).every((permission) =>
resolveServerPermission(server, actorUserId, permission, actorOderId)
);
}
export function canModerateServerMember(
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
actorUserId: string,
targetUserId: string,
permission: 'kickMembers' | 'banMembers' | 'manageBans',
actorOderId?: string,
targetOderId?: string
): boolean {
if (!actorUserId || !targetUserId || actorUserId === targetUserId) {
return false;
}
if (isServerOwner(server, targetUserId) && !isServerOwner(server, actorUserId)) {
return false;
}
if (isServerOwner(server, actorUserId)) {
return true;
}
if (!resolveServerPermission(server, actorUserId, permission, actorOderId)) {
return false;
}
const actorRole = resolveHighestRole(server, {
userId: actorUserId,
oderId: actorOderId
});
const targetRole = resolveHighestRole(server, {
userId: targetUserId,
oderId: targetOderId
});
return (actorRole?.position ?? 0) > (targetRole?.position ?? 0);
}

View File

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

View File

@@ -26,7 +26,7 @@
@if (isThemeStudioFullscreen()) { @if (isThemeStudioFullscreen()) {
<div class="theme-studio-fullscreen-shell absolute inset-0 overflow-y-auto overflow-x-hidden bg-background"> <div class="theme-studio-fullscreen-shell absolute inset-0 overflow-y-auto overflow-x-hidden bg-background">
@if (themeStudioFullscreenComponent()) { @if (themeStudioFullscreenComponent()) {
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()"></ng-container> <ng-container *ngComponentOutlet="themeStudioFullscreenComponent()" />
} @else { } @else {
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio…</div> <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 { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions'; import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors'; import { selectCurrentRoom } from './store/rooms/rooms.selectors';
import { import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID } from './core/constants';
ROOM_URL_PATTERN,
STORAGE_KEY_CURRENT_USER_ID
} from './core/constants';
import { import {
ThemeNodeDirective, ThemeNodeDirective,
ThemePickerOverlayComponent, ThemePickerOverlayComponent,
@@ -241,7 +238,6 @@ export class App implements OnInit, OnDestroy {
this.router.events.subscribe((evt) => { this.router.events.subscribe((evt) => {
if (evt instanceof NavigationEnd) { if (evt instanceof NavigationEnd) {
const url = evt.urlAfterRedirects || evt.url; const url = evt.urlAfterRedirects || evt.url;
const roomMatch = url.match(ROOM_URL_PATTERN); const roomMatch = url.match(ROOM_URL_PATTERN);
const currentRoomId = roomMatch ? roomMatch[1] : null; const currentRoomId = roomMatch ? roomMatch[1] : null;
@@ -274,14 +270,17 @@ export class App implements OnInit, OnDestroy {
width: rect.width, width: rect.width,
height: rect.height height: rect.height
}; };
this.themeStudioControlsDragOffset = { this.themeStudioControlsDragOffset = {
x: event.clientX - rect.left, x: event.clientX - rect.left,
y: event.clientY - rect.top y: event.clientY - rect.top
}; };
this.themeStudioControlsPosition.set({ this.themeStudioControlsPosition.set({
x: rect.left, x: rect.left,
y: rect.top y: rect.top
}); });
this.isDraggingThemeStudioControls.set(true); this.isDraggingThemeStudioControls.set(true);
event.preventDefault(); event.preventDefault();
} }

View File

@@ -6,23 +6,25 @@ infrastructure adapters and UI.
## Quick reference ## Quick reference
| Domain | Purpose | Public entry point | | Domain | Purpose | Public entry point |
|---|---|---| | -------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` | | **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` | | **access-control** | Role, permission, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | | **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | | **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` | | **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` | | **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` | | **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` | | **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
## Detailed docs ## Detailed docs
The larger domains also keep longer design notes in their own folders: The larger domains also keep longer design notes in their own folders:
- [attachment/README.md](attachment/README.md) - [attachment/README.md](attachment/README.md)
- [access-control/README.md](access-control/README.md)
- [auth/README.md](auth/README.md) - [auth/README.md](auth/README.md)
- [chat/README.md](chat/README.md) - [chat/README.md](chat/README.md)
- [notifications/README.md](notifications/README.md) - [notifications/README.md](notifications/README.md)
@@ -66,12 +68,12 @@ domains/<name>/
## Where do I put new code? ## Where do I put new code?
| I want to… | Put it in… | | I want to… | Put it in… |
|---|---| | --------------------------------------- | ----------------------------------------------------------------- |
| Add a new business concept | New folder under `domains/` following the convention above | | 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 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 UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
| Add a settings subpanel | `domains/<name>/feature/settings/` | | Add a settings subpanel | `domains/<name>/feature/settings/` |
| Add a top-level page or shell component | `features/` | | Add a top-level page or shell component | `features/` |
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` | | Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
| Add realtime/WebRTC logic | `infrastructure/realtime/` | | 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> <div #composerRoot>
@if (replyTo()) { @if (replyTo()) {
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2"> <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) { @if (msg.isDeleted) {
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div> <div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
} @else { } @else {
<div class="chat-markdown mt-1 break-words"> @if (requiresRichMarkdown(msg.content)) {
<remark @defer {
[markdown]="msg.content" <div class="chat-markdown mt-1 break-words">
[processor]="$any(remarkProcessor)" <app-chat-message-markdown [content]="msg.content" />
> </div>
<ng-template } @placeholder {
[remarkTemplate]="'code'" <div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
let-node }
> } @else {
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) { <div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
<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 (attachmentsList.length > 0) { @if (attachmentsList.length > 0) {
<div class="mt-2 space-y-2"> <div class="mt-2 space-y-2">

View File

@@ -24,11 +24,6 @@ import {
lucideTrash2, lucideTrash2,
lucideX lucideX
} from '@ng-icons/lucide'; } 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 { import {
Attachment, Attachment,
AttachmentFacade, AttachmentFacade,
@@ -41,7 +36,7 @@ import {
ChatVideoPlayerComponent, ChatVideoPlayerComponent,
UserAvatarComponent UserAvatarComponent
} from '../../../../../../shared'; } from '../../../../../../shared';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive'; import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
import { import {
ChatMessageDeleteEvent, ChatMessageDeleteEvent,
ChatMessageEditEvent, ChatMessageEditEvent,
@@ -60,28 +55,7 @@ const COMMON_EMOJIS = [
'🔥', '🔥',
'👀' '👀'
]; ];
const PRISM_LANGUAGE_ALIASES: Record<string, string> = { const RICH_MARKDOWN_PATTERN = /(^|\n)(#{1,6}\s|>\s|[-*+]\s|\d+\.\s|```|~~~)|!\[[^\]]*\]\([^)]+\)|\[[^\]]+\]\([^)]+\)|`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|_[^_\n]+_|(?:^|\n)\|.+\|/m;
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);
interface ChatMessageAttachmentViewModel extends Attachment { interface ChatMessageAttachmentViewModel extends Attachment {
isAudio: boolean; isAudio: boolean;
@@ -101,9 +75,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
NgIcon, NgIcon,
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
ChatVideoPlayerComponent, ChatVideoPlayerComponent,
RemarkModule, ChatMessageMarkdownComponent,
MermaidComponent,
ChatImageProxyFallbackDirective,
UserAvatarComponent UserAvatarComponent
], ],
viewProviders: [ viewProviders: [
@@ -136,7 +108,6 @@ export class ChatMessageItemComponent {
readonly repliedMessage = input<Message | undefined>(); readonly repliedMessage = input<Message | undefined>();
readonly currentUserId = input<string | null>(null); readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false); readonly isAdmin = input(false);
readonly remarkProcessor = REMARK_PROCESSOR;
readonly replyRequested = output<ChatMessageReplyEvent>(); readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>(); readonly deleteRequested = output<ChatMessageDeleteEvent>();
@@ -320,23 +291,8 @@ export class ChatMessageItemComponent {
); );
} }
getMermaidCode(code?: string): string { requiresRichMarkdown(content: string): boolean {
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim(); return RICH_MARKDOWN_PATTERN.test(content);
}
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)}`;
} }
formatBytes(bytes: number): string { formatBytes(bytes: number): string {
@@ -468,15 +424,6 @@ export class ChatMessageItemComponent {
this.downloadRequested.emit(attachment); 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 { private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
const isVideo = this.isVideoAttachment(attachment); const isVideo = this.isVideoAttachment(attachment);
const isAudio = this.isAudioAttachment(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(() => { readonly dateSeparatorLabels = computed(() => {
const labels = new Map<number, string>(); const labels = new Map<number, string>();
let previousDayKey: string | null = null; let previousDayKey: string | null = null;
this.messages().forEach((message, index) => { this.messages().forEach((message, index) => {

View File

@@ -1,4 +1,3 @@
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
<div <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" 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" role="dialog"

View File

@@ -9,10 +9,7 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
selectActiveChannelId,
selectCurrentRoom
} from '../../../../store/rooms/rooms.selectors';
import { import {
merge, merge,
interval, interval,

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* 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 { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -16,10 +20,7 @@ import { NotificationsFacade } from '../../application/notifications.facade';
@Component({ @Component({
selector: 'app-notifications-settings', selector: 'app-notifications-settings',
standalone: true, standalone: true,
imports: [ imports: [CommonModule, NgIcon],
CommonModule,
NgIcon
],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideBell, lucideBell,
@@ -49,10 +50,12 @@ export class NotificationsSettingsComponent {
return channels.length > 0 return channels.length > 0
? channels ? channels
: [{ id: 'general', : [
name: 'general', { id: 'general',
type: 'text', name: 'general',
position: 0 }]; type: 'text',
position: 0 }
];
} }
onNotificationsEnabledChange(event: Event): void { 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'; export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
@@ -17,6 +22,10 @@ export interface ServerInfo {
isPrivate: boolean; isPrivate: boolean;
tags?: string[]; tags?: string[];
channels?: Channel[]; channels?: Channel[];
slowModeInterval?: number;
roles?: RoomRole[];
roleAssignments?: RoomRoleAssignment[];
channelPermissions?: ChannelPermissionOverride[];
createdAt: number; createdAt: number;
sourceId?: string; sourceId?: string;
sourceName?: string; sourceName?: string;

View File

@@ -9,7 +9,11 @@ import {
} from 'rxjs'; } from 'rxjs';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { import {
ChannelPermissionOverride,
type Channel, type Channel,
ROOM_PERMISSION_KEYS,
RoomRole,
RoomRoleAssignment,
User User
} from '../../../shared-kernel'; } from '../../../shared-kernel';
import { ServerEndpointStateService } from '../application/server-endpoint-state.service'; 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.findServerByUrl(selector.sourceUrl) ?? null;
} }
return this.endpointState.activeServer() return (
?? this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible') this.endpointState.activeServer() ??
?? this.endpointState.servers()[0] this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible') ??
?? null; this.endpointState.servers()[0] ??
null
);
} }
searchServers(query: string, shouldSearchAllServers: boolean): Observable<ServerInfo[]> { searchServers(query: string, shouldSearchAllServers: boolean): Observable<ServerInfo[]> {
@@ -67,41 +73,35 @@ export class ServerDirectoryApiService {
return this.getAllServersFromAllEndpoints(); return this.getAllServersFromAllEndpoints();
} }
return this.http return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`).pipe(
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`) map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
.pipe( catchError((error) => {
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())), console.error('Failed to get servers:', error);
catchError((error) => { return of([]);
console.error('Failed to get servers:', error); })
return of([]); );
})
);
} }
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> { getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
return this.http return this.http.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`) map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
.pipe( catchError((error) => {
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))), console.error('Failed to get server:', error);
catchError((error) => { return of(null);
console.error('Failed to get server:', error); })
return of(null); );
})
);
} }
registerServer( registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null }, server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
selector?: ServerSourceSelector selector?: ServerSourceSelector
): Observable<ServerInfo> { ): Observable<ServerInfo> {
return this.http return this.http.post<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers`, server).pipe(
.post<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers`, server) catchError((error) => {
.pipe( console.error('Failed to register server:', error);
catchError((error) => { return throwError(() => error);
console.error('Failed to register server:', error); })
return throwError(() => error); );
})
);
} }
updateServer( updateServer(
@@ -113,157 +113,111 @@ export class ServerDirectoryApiService {
}, },
selector?: ServerSourceSelector selector?: ServerSourceSelector
): Observable<ServerInfo> { ): Observable<ServerInfo> {
return this.http return this.http.put<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates).pipe(
.put<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates) catchError((error) => {
.pipe( console.error('Failed to update server:', error);
catchError((error) => { return throwError(() => error);
console.error('Failed to update server:', error); })
return throwError(() => error); );
})
);
} }
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> { unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
return this.http return this.http.delete<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
.delete<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`) catchError((error) => {
.pipe( console.error('Failed to unregister server:', error);
catchError((error) => { return throwError(() => error);
console.error('Failed to unregister server:', error); })
return throwError(() => error); );
})
);
} }
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> { getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
return this.http return this.http.get<User[]>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`).pipe(
.get<User[]>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`) catchError((error) => {
.pipe( console.error('Failed to get server users:', error);
catchError((error) => { return of([]);
console.error('Failed to get server users:', error); })
return of([]); );
})
);
} }
requestJoin( requestJoin(request: ServerJoinAccessRequest, selector?: ServerSourceSelector): Observable<ServerJoinAccessResponse> {
request: ServerJoinAccessRequest, return this.http.post<ServerJoinAccessResponse>(`${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`, request).pipe(
selector?: ServerSourceSelector catchError((error) => {
): Observable<ServerJoinAccessResponse> { console.error('Failed to send join request:', error);
return this.http return throwError(() => error);
.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( createInvite(serverId: string, request: CreateServerInviteRequest, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
serverId: string, return this.http.post<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request).pipe(
request: CreateServerInviteRequest, catchError((error) => {
selector?: ServerSourceSelector console.error('Failed to create invite:', error);
): Observable<ServerInviteInfo> { return throwError(() => error);
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> { getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
return this.http return this.http.get<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`).pipe(
.get<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`) catchError((error) => {
.pipe( console.error('Failed to get invite:', error);
catchError((error) => { return throwError(() => error);
console.error('Failed to get invite:', error); })
return throwError(() => error); );
})
);
} }
kickServerMember( kickServerMember(serverId: string, request: KickServerMemberRequest, selector?: ServerSourceSelector): Observable<void> {
serverId: string, return this.http.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request).pipe(
request: KickServerMemberRequest, catchError((error) => {
selector?: ServerSourceSelector console.error('Failed to kick server member:', error);
): Observable<void> { return throwError(() => error);
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( banServerMember(serverId: string, request: BanServerMemberRequest, selector?: ServerSourceSelector): Observable<void> {
serverId: string, return this.http.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request).pipe(
request: BanServerMemberRequest, catchError((error) => {
selector?: ServerSourceSelector console.error('Failed to ban server member:', error);
): Observable<void> { return throwError(() => error);
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( unbanServerMember(serverId: string, request: UnbanServerMemberRequest, selector?: ServerSourceSelector): Observable<void> {
serverId: string, return this.http.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request).pipe(
request: UnbanServerMemberRequest, catchError((error) => {
selector?: ServerSourceSelector console.error('Failed to unban server member:', error);
): Observable<void> { return throwError(() => error);
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> { notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
return this.http return this.http.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId }).pipe(
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId }) catchError((error) => {
.pipe( console.error('Failed to notify leave:', error);
catchError((error) => { return of(undefined);
console.error('Failed to notify leave:', error); })
return of(undefined); );
})
);
} }
updateUserCount(serverId: string, count: number): Observable<void> { updateUserCount(serverId: string, count: number): Observable<void> {
return this.http return this.http.patch<void>(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count }).pipe(
.patch<void>(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count }) catchError((error) => {
.pipe( console.error('Failed to update user count:', error);
catchError((error) => { return of(undefined);
console.error('Failed to update user count:', error); })
return of(undefined); );
})
);
} }
sendHeartbeat(serverId: string): Observable<void> { sendHeartbeat(serverId: string): Observable<void> {
return this.http return this.http.post<void>(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {}).pipe(
.post<void>(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {}) catchError((error) => {
.pipe( console.error('Failed to send heartbeat:', error);
catchError((error) => { return of(undefined);
console.error('Failed to send heartbeat:', error); })
return of(undefined); );
})
);
} }
private resolveBaseServerUrl(selector?: ServerSourceSelector): string { private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
@@ -274,71 +228,51 @@ export class ServerDirectoryApiService {
return this.resolveEndpoint(selector)?.url ?? this.endpointState.getPrimaryDefaultServerUrl(); return this.resolveEndpoint(selector)?.url ?? this.endpointState.getPrimaryDefaultServerUrl();
} }
private unwrapServersResponse( private unwrapServersResponse(response: { servers: ServerInfo[]; total: number } | ServerInfo[]): ServerInfo[] {
response: { servers: ServerInfo[]; total: number } | ServerInfo[] return Array.isArray(response) ? response : (response.servers ?? []);
): ServerInfo[] {
return Array.isArray(response)
? response
: (response.servers ?? []);
} }
private searchSingleEndpoint( private searchSingleEndpoint(query: string, apiBaseUrl: string, source?: ServerEndpoint | null): Observable<ServerInfo[]> {
query: string,
apiBaseUrl: string,
source?: ServerEndpoint | null
): Observable<ServerInfo[]> {
const params = new HttpParams().set('q', query); const params = new HttpParams().set('q', query);
return this.http return this.http.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params }).pipe(
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params }) map((response) => this.normalizeServerList(response, source)),
.pipe( catchError((error) => {
map((response) => this.normalizeServerList(response, source)), console.error('Failed to search servers:', error);
catchError((error) => { return of([]);
console.error('Failed to search servers:', error); })
return of([]); );
})
);
} }
private searchAllEndpoints(query: string): Observable<ServerInfo[]> { private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
const onlineEndpoints = this.endpointState.activeServers().filter( const onlineEndpoints = this.endpointState.activeServers().filter((endpoint) => endpoint.status !== 'offline');
(endpoint) => endpoint.status !== 'offline'
);
if (onlineEndpoints.length === 0) { if (onlineEndpoints.length === 0) {
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer()); return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
} }
return forkJoin( return forkJoin(onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))).pipe(
onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))
).pipe(
map((resultArrays) => resultArrays.flat()), map((resultArrays) => resultArrays.flat()),
map((servers) => this.deduplicateById(servers)) map((servers) => this.deduplicateById(servers))
); );
} }
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> { private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
const onlineEndpoints = this.endpointState.activeServers().filter( const onlineEndpoints = this.endpointState.activeServers().filter((endpoint) => endpoint.status !== 'offline');
(endpoint) => endpoint.status !== 'offline'
);
if (onlineEndpoints.length === 0) { if (onlineEndpoints.length === 0) {
return this.http return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`).pipe(
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`) map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
.pipe( catchError(() => of([]))
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())), );
catchError(() => of([]))
);
} }
return forkJoin( return forkJoin(
onlineEndpoints.map((endpoint) => onlineEndpoints.map((endpoint) =>
this.http this.http.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`).pipe(
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`) map((response) => this.normalizeServerList(response, endpoint)),
.pipe( catchError(() => of([] as ServerInfo[]))
map((response) => this.normalizeServerList(response, endpoint)), )
catchError(() => of([] as ServerInfo[]))
)
) )
).pipe(map((resultArrays) => resultArrays.flat())); ).pipe(map((resultArrays) => resultArrays.flat()));
} }
@@ -356,17 +290,11 @@ export class ServerDirectoryApiService {
}); });
} }
private normalizeServerList( private normalizeServerList(response: { servers: ServerInfo[]; total: number } | ServerInfo[], source?: ServerEndpoint | null): ServerInfo[] {
response: { servers: ServerInfo[]; total: number } | ServerInfo[],
source?: ServerEndpoint | null
): ServerInfo[] {
return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source)); return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source));
} }
private normalizeServerInfo( private normalizeServerInfo(server: ServerInfo | Record<string, unknown>, source?: ServerEndpoint | null): ServerInfo {
server: ServerInfo | Record<string, unknown>,
source?: ServerEndpoint | null
): ServerInfo {
const candidate = server as Record<string, unknown>; const candidate = server as Record<string, unknown>;
const sourceName = this.getStringValue(candidate['sourceName']); const sourceName = this.getStringValue(candidate['sourceName']);
const sourceUrl = this.getStringValue(candidate['sourceUrl']); const sourceUrl = this.getStringValue(candidate['sourceUrl']);
@@ -384,14 +312,16 @@ export class ServerDirectoryApiService {
maxUsers: this.getNumberValue(candidate['maxUsers']), maxUsers: this.getNumberValue(candidate['maxUsers']),
hasPassword: this.getBooleanValue(candidate['hasPassword']), hasPassword: this.getBooleanValue(candidate['hasPassword']),
isPrivate: this.getBooleanValue(candidate['isPrivate']), 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']), 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()), createdAt: this.getNumberValue(candidate['createdAt'], Date.now()),
sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id, sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
sourceName: sourceName ?? source?.name, sourceName: sourceName ?? source?.name,
sourceUrl: sourceUrl sourceUrl: sourceUrl ? this.endpointState.sanitiseUrl(sourceUrl) : source ? this.endpointState.sanitiseUrl(source.url) : undefined
? this.endpointState.sanitiseUrl(sourceUrl)
: (source ? this.endpointState.sanitiseUrl(source.url) : undefined)
}; };
} }
@@ -430,6 +360,123 @@ export class ServerDirectoryApiService {
.filter((channel): channel is Channel => !!channel); .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 { private getChannelTypeValue(value: unknown): Channel['type'] | undefined {
return value === 'text' || value === 'voice' ? value : undefined; return value === 'text' || value === 'voice' ? value : undefined;
} }

View File

@@ -1,10 +1,11 @@
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { Injectable, inject, signal } from '@angular/core';
import { import {
SettingsModalService, Injectable,
type SettingsPage inject,
} from '../../../core/services/settings-modal.service'; signal
} from '@angular/core';
import { SettingsModalService, type SettingsPage } from '../../../core/services/settings-modal.service';
import { ThemeRegistryService } from './theme-registry.service'; import { ThemeRegistryService } from './theme-registry.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -13,7 +14,7 @@ export class ElementPickerService {
private readonly modal = inject(SettingsModalService); private readonly modal = inject(SettingsModalService);
private readonly registry = inject(ThemeRegistryService); private readonly registry = inject(ThemeRegistryService);
private removeListeners: Array<() => void> = []; private removeListeners: (() => void)[] = [];
private resumePage: SettingsPage | null = null; private resumePage: SettingsPage | null = null;
private shouldRestoreModalOnCancel = true; private shouldRestoreModalOnCancel = true;
@@ -69,7 +70,6 @@ export class ElementPickerService {
this.hoveredKey.set(key); this.hoveredKey.set(key);
}; };
const onClick = (event: Event) => { const onClick = (event: Event) => {
const key = this.resolveThemeKeyFromTarget(event.target); const key = this.resolveThemeKeyFromTarget(event.target);
@@ -86,7 +86,6 @@ export class ElementPickerService {
this.completePick(key); this.completePick(key);
}; };
const onKeyDown = (event: Event) => { const onKeyDown = (event: Event) => {
const keyboardEvent = event as KeyboardEvent; const keyboardEvent = event as KeyboardEvent;
@@ -129,4 +128,4 @@ export class ElementPickerService {
? key ? key
: null; : null;
} }
} }

View File

@@ -1,4 +1,8 @@
import { Injectable, computed, inject } from '@angular/core'; import {
Injectable,
computed,
inject
} from '@angular/core';
import { import {
ThemeContainerKey, ThemeContainerKey,
@@ -55,4 +59,4 @@ export class LayoutSyncService {
} }
}, true, `${containerKey} restored to its default layout.`); }, 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 type { SavedThemeSummary } from '../domain/theme.models';
import { ThemeLibraryStorageService } from '../infrastructure/theme-library.storage'; import { ThemeLibraryStorageService } from '../infrastructure/theme-library.storage';
import { ThemeService } from './theme.service'; import { ThemeService } from './theme.service';
@@ -173,4 +178,4 @@ export class ThemeLibraryService {
this.selectedFileName.set(entries[0]?.fileName ?? null); this.selectedFileName.set(entries[0]?.fileName ?? null);
} }
} }

View File

@@ -1,9 +1,6 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
import { import { ThemeLayoutContainerDefinition, ThemeRegistryEntry } from '../domain/theme.models';
ThemeLayoutContainerDefinition,
ThemeRegistryEntry
} from '../domain/theme.models';
import { import {
THEME_LAYOUT_CONTAINERS, THEME_LAYOUT_CONTAINERS,
THEME_REGISTRY, THEME_REGISTRY,
@@ -85,4 +82,4 @@ export class ThemeRegistryService {
this.mountedCounts.set(nextCounts); this.mountedCounts.set(nextCounts);
} }
} }

View File

@@ -1,5 +1,10 @@
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { Injectable, computed, inject, signal } from '@angular/core'; import {
Injectable,
computed,
inject,
signal
} from '@angular/core';
import { import {
ThemeAnimationDefinition, ThemeAnimationDefinition,
@@ -13,12 +18,8 @@ import {
createDefaultThemeDocument, createDefaultThemeDocument,
isLegacyDefaultThemeDocument isLegacyDefaultThemeDocument
} from '../domain/theme.defaults'; } from '../domain/theme.defaults';
import { import { createAnimationStarterDefinition } from '../domain/theme.schema';
createAnimationStarterDefinition import { findThemeLayoutContainer } from '../domain/theme.registry';
} from '../domain/theme.schema';
import {
findThemeLayoutContainer
} from '../domain/theme.registry';
import { validateThemeDocument } from '../domain/theme.validation'; import { validateThemeDocument } from '../domain/theme.validation';
import { import {
loadThemeStorageSnapshot, loadThemeStorageSnapshot,
@@ -280,28 +281,71 @@ export class ThemeService {
styles['backgroundImage'] = backgroundLayers.join(', '); styles['backgroundImage'] = backgroundLayers.join(', ');
} }
if (elementTheme.width) styles['width'] = elementTheme.width; if (elementTheme.width)
if (elementTheme.height) styles['height'] = elementTheme.height; styles['width'] = elementTheme.width;
if (elementTheme.minWidth) styles['minWidth'] = elementTheme.minWidth;
if (elementTheme.minHeight) styles['minHeight'] = elementTheme.minHeight; if (elementTheme.height)
if (elementTheme.maxWidth) styles['maxWidth'] = elementTheme.maxWidth; styles['height'] = elementTheme.height;
if (elementTheme.maxHeight) styles['maxHeight'] = elementTheme.maxHeight;
if (elementTheme.position) styles['position'] = elementTheme.position; if (elementTheme.minWidth)
if (elementTheme.top) styles['top'] = elementTheme.top; styles['minWidth'] = elementTheme.minWidth;
if (elementTheme.right) styles['right'] = elementTheme.right;
if (elementTheme.bottom) styles['bottom'] = elementTheme.bottom; if (elementTheme.minHeight)
if (elementTheme.left) styles['left'] = elementTheme.left; styles['minHeight'] = elementTheme.minHeight;
if (elementTheme.padding) styles['padding'] = elementTheme.padding;
if (elementTheme.margin) styles['margin'] = elementTheme.margin; if (elementTheme.maxWidth)
if (elementTheme.border) styles['border'] = elementTheme.border; styles['maxWidth'] = elementTheme.maxWidth;
if (elementTheme.borderRadius) styles['borderRadius'] = elementTheme.borderRadius;
if (elementTheme.backgroundColor) styles['backgroundColor'] = elementTheme.backgroundColor; if (elementTheme.maxHeight)
if (elementTheme.color) styles['color'] = elementTheme.color; styles['maxHeight'] = elementTheme.maxHeight;
if (elementTheme.backgroundSize) styles['backgroundSize'] = elementTheme.backgroundSize;
if (elementTheme.backgroundPosition) styles['backgroundPosition'] = elementTheme.backgroundPosition; if (elementTheme.position)
if (elementTheme.backgroundRepeat) styles['backgroundRepeat'] = elementTheme.backgroundRepeat; styles['position'] = elementTheme.position;
if (elementTheme.boxShadow) styles['boxShadow'] = elementTheme.boxShadow;
if (elementTheme.backdropFilter) styles['backdropFilter'] = elementTheme.backdropFilter; 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') { if (typeof elementTheme.opacity === 'number') {
styles['opacity'] = `${elementTheme.opacity}`; styles['opacity'] = `${elementTheme.opacity}`;
@@ -512,4 +556,4 @@ export class ThemeService {
this.statusTimeoutId = null; this.statusTimeoutId = null;
}, 5000); }, 5000);
} }
} }

View File

@@ -1,8 +1,5 @@
import { DEFAULT_THEME_DOCUMENT } from './theme.defaults'; import { DEFAULT_THEME_DOCUMENT } from './theme.defaults';
import { import { THEME_LAYOUT_CONTAINERS, THEME_REGISTRY } from './theme.registry';
THEME_LAYOUT_CONTAINERS,
THEME_REGISTRY
} from './theme.registry';
import { import {
THEME_ANIMATION_FIELDS, THEME_ANIMATION_FIELDS,
THEME_ELEMENT_STYLE_FIELDS, THEME_ELEMENT_STYLE_FIELDS,
@@ -168,4 +165,4 @@ export const THEME_LLM_GUIDE = [
'- Keep layout edits plausible for the declared container grid size.', '- Keep layout edits plausible for the declared container grid size.',
'- If a field is unsupported, omit it instead of guessing.', '- 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.' '- 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, w: 1,
h: 1 } h: 1 }
}; };
continue; continue;
} }
@@ -40,6 +41,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
w: (appShell?.columns ?? 20) - 1, w: (appShell?.columns ?? 20) - 1,
h: 1 } h: 1 }
}; };
continue; continue;
} }
@@ -51,6 +53,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
w: 4, w: 4,
h: 12 } h: 12 }
}; };
continue; continue;
} }
@@ -62,6 +65,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
w: 12, w: 12,
h: 12 } h: 12 }
}; };
continue; continue;
} }
@@ -87,16 +91,19 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
color: 'hsl(var(--foreground))', 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))' 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'] = { elements['serversRail'] = {
backgroundColor: 'hsl(var(--rail-background) / 0.96)', backgroundColor: 'hsl(var(--rail-background) / 0.96)',
gradient: 'linear-gradient(180deg, rgba(10, 14, 25, 0.92), rgba(6, 9, 16, 0.98))', 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)', 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)' backdropFilter: 'var(--theme-effect-glass-blur)'
}; };
elements['appWorkspace'] = { elements['appWorkspace'] = {
backgroundColor: 'hsl(var(--workspace-background))', 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))' 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'] = { elements['titleBar'] = {
backgroundColor: 'hsl(var(--title-bar-background) / 0.82)', backgroundColor: 'hsl(var(--title-bar-background) / 0.82)',
color: 'hsl(var(--foreground))', 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)', 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)' backdropFilter: 'var(--theme-effect-glass-blur)'
}; };
elements['chatRoomChannelsPanel'] = { elements['chatRoomChannelsPanel'] = {
backgroundColor: 'hsl(var(--panel-background) / 0.9)', backgroundColor: 'hsl(var(--panel-background) / 0.9)',
color: 'hsl(var(--foreground))', color: 'hsl(var(--foreground))',
@@ -113,6 +121,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
boxShadow: 'var(--theme-effect-soft-shadow)', boxShadow: 'var(--theme-effect-soft-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)' backdropFilter: 'var(--theme-effect-glass-blur)'
}; };
elements['chatRoomMainPanel'] = { elements['chatRoomMainPanel'] = {
backgroundColor: 'hsl(var(--panel-background) / 0.82)', backgroundColor: 'hsl(var(--panel-background) / 0.82)',
color: 'hsl(var(--foreground))', color: 'hsl(var(--foreground))',
@@ -122,6 +131,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
boxShadow: 'var(--theme-effect-panel-shadow)', boxShadow: 'var(--theme-effect-panel-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)' backdropFilter: 'var(--theme-effect-glass-blur)'
}; };
elements['chatRoomMembersPanel'] = { elements['chatRoomMembersPanel'] = {
backgroundColor: 'hsl(var(--panel-background-alt) / 0.92)', backgroundColor: 'hsl(var(--panel-background-alt) / 0.92)',
color: 'hsl(var(--foreground))', color: 'hsl(var(--foreground))',
@@ -131,6 +141,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
boxShadow: 'var(--theme-effect-soft-shadow)', boxShadow: 'var(--theme-effect-soft-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)' backdropFilter: 'var(--theme-effect-glass-blur)'
}; };
elements['chatRoomEmptyState'] = { elements['chatRoomEmptyState'] = {
backgroundColor: 'hsl(var(--panel-background-alt) / 0.88)', backgroundColor: 'hsl(var(--panel-background-alt) / 0.88)',
color: 'hsl(var(--muted-foreground))', 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%)', gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.08), transparent 45%)',
boxShadow: 'var(--theme-effect-soft-shadow)' boxShadow: 'var(--theme-effect-soft-shadow)'
}; };
elements['voiceWorkspace'] = { elements['voiceWorkspace'] = {
backgroundColor: 'hsl(var(--panel-background) / 0.74)', backgroundColor: 'hsl(var(--panel-background) / 0.74)',
color: 'hsl(var(--foreground))', color: 'hsl(var(--foreground))',
@@ -148,6 +160,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
boxShadow: 'var(--theme-effect-panel-shadow)', boxShadow: 'var(--theme-effect-panel-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)' backdropFilter: 'var(--theme-effect-glass-blur)'
}; };
elements['floatingVoiceControls'] = { elements['floatingVoiceControls'] = {
backgroundColor: 'hsl(var(--panel-background-alt) / 0.94)', backgroundColor: 'hsl(var(--panel-background-alt) / 0.94)',
color: 'hsl(var(--foreground))', 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_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'; type: 'string' | 'number' | 'object';
example: string | number; example: string | number;
examples: readonly (string | number)[]; examples: readonly (string | number)[];
} }

View File

@@ -1,7 +1,4 @@
import { import { ThemeLayoutContainerDefinition, ThemeRegistryEntry } from './theme.models';
ThemeLayoutContainerDefinition,
ThemeRegistryEntry
} from './theme.models';
export const THEME_LAYOUT_CONTAINERS: readonly ThemeLayoutContainerDefinition[] = [ export const THEME_LAYOUT_CONTAINERS: readonly ThemeLayoutContainerDefinition[] = [
{ {
@@ -158,4 +155,4 @@ export function getPickerVisibleThemeKeys(): string[] {
return THEME_REGISTRY return THEME_REGISTRY
.filter((entry) => entry.pickerVisible) .filter((entry) => entry.pickerVisible)
.map((entry) => entry.key); .map((entry) => entry.key);
} }

View File

@@ -96,28 +96,44 @@ export const THEME_GRID_FIELDS: readonly ThemeSchemaField[] = [
description: 'Horizontal grid start column, zero-based.', description: 'Horizontal grid start column, zero-based.',
type: 'number', type: 'number',
example: 4, example: 4,
examples: [0, 1, 4] examples: [
0,
1,
4
]
}, },
{ {
key: 'y', key: 'y',
description: 'Vertical grid start row, zero-based.', description: 'Vertical grid start row, zero-based.',
type: 'number', type: 'number',
example: 0, example: 0,
examples: [0, 1, 6] examples: [
0,
1,
6
]
}, },
{ {
key: 'w', key: 'w',
description: 'Grid width in columns.', description: 'Grid width in columns.',
type: 'number', type: 'number',
example: 12, example: 12,
examples: [1, 4, 12] examples: [
1,
4,
12
]
}, },
{ {
key: 'h', key: 'h',
description: 'Grid height in rows.', description: 'Grid height in rows.',
type: 'number', type: 'number',
example: 12, example: 12,
examples: [1, 4, 12] examples: [
1,
4,
12
]
} }
]; ];
@@ -127,14 +143,22 @@ export const THEME_ANIMATION_FIELDS: readonly ThemeSchemaField[] = [
description: 'Animation duration.', description: 'Animation duration.',
type: 'string', type: 'string',
example: '240ms', example: '240ms',
examples: ['200ms', '240ms', '600ms'] examples: [
'200ms',
'240ms',
'600ms'
]
}, },
{ {
key: 'easing', key: 'easing',
description: 'Animation easing function.', description: 'Animation easing function.',
type: 'string', type: 'string',
example: 'ease-out', 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', key: 'delay',
@@ -155,14 +179,23 @@ export const THEME_ANIMATION_FIELDS: readonly ThemeSchemaField[] = [
description: 'Animation fill behavior after running.', description: 'Animation fill behavior after running.',
type: 'string', type: 'string',
example: 'both', example: 'both',
examples: ['none', 'forwards', 'backwards', 'both'] examples: [
'none',
'forwards',
'backwards',
'both'
]
}, },
{ {
key: 'direction', key: 'direction',
description: 'Animation direction.', description: 'Animation direction.',
type: 'string', type: 'string',
example: 'normal', example: 'normal',
examples: ['normal', 'reverse', 'alternate'] examples: [
'normal',
'reverse',
'alternate'
]
}, },
{ {
key: 'keyframes', key: 'keyframes',
@@ -179,14 +212,22 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
description: 'CSS width applied to the selected element host.', description: 'CSS width applied to the selected element host.',
type: 'string', type: 'string',
example: '280px', example: '280px',
examples: ['280px', '20rem', 'min(24rem, 30vw)'] examples: [
'280px',
'20rem',
'min(24rem, 30vw)'
]
}, },
{ {
key: 'height', key: 'height',
description: 'CSS height applied to the selected element host.', description: 'CSS height applied to the selected element host.',
type: 'string', type: 'string',
example: '100%', example: '100%',
examples: ['100%', '22rem', 'calc(100vh - 4rem)'] examples: [
'100%',
'22rem',
'calc(100vh - 4rem)'
]
}, },
{ {
key: 'minWidth', key: 'minWidth',
@@ -221,56 +262,89 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
description: 'CSS positioning mode for the host element.', description: 'CSS positioning mode for the host element.',
type: 'string', type: 'string',
example: 'relative', example: 'relative',
examples: ['static', 'relative', 'absolute', 'sticky'] examples: [
'static',
'relative',
'absolute',
'sticky'
]
}, },
{ {
key: 'top', key: 'top',
description: 'CSS top inset used with positioned elements.', description: 'CSS top inset used with positioned elements.',
type: 'string', type: 'string',
example: '12px', example: '12px',
examples: ['0', '12px', '2rem'] examples: [
'0',
'12px',
'2rem'
]
}, },
{ {
key: 'right', key: 'right',
description: 'CSS right inset used with positioned elements.', description: 'CSS right inset used with positioned elements.',
type: 'string', type: 'string',
example: '12px', example: '12px',
examples: ['0', '12px', '2rem'] examples: [
'0',
'12px',
'2rem'
]
}, },
{ {
key: 'bottom', key: 'bottom',
description: 'CSS bottom inset used with positioned elements.', description: 'CSS bottom inset used with positioned elements.',
type: 'string', type: 'string',
example: '12px', example: '12px',
examples: ['0', '12px', '2rem'] examples: [
'0',
'12px',
'2rem'
]
}, },
{ {
key: 'left', key: 'left',
description: 'CSS left inset used with positioned elements.', description: 'CSS left inset used with positioned elements.',
type: 'string', type: 'string',
example: '12px', example: '12px',
examples: ['0', '12px', '2rem'] examples: [
'0',
'12px',
'2rem'
]
}, },
{ {
key: 'opacity', key: 'opacity',
description: 'Element opacity between 0 and 1.', description: 'Element opacity between 0 and 1.',
type: 'number', type: 'number',
example: 0.96, example: 0.96,
examples: [0.72, 0.88, 1] examples: [
0.72,
0.88,
1
]
}, },
{ {
key: 'padding', key: 'padding',
description: 'CSS padding shorthand for internal spacing.', description: 'CSS padding shorthand for internal spacing.',
type: 'string', type: 'string',
example: '12px', example: '12px',
examples: ['12px', '12px 16px', '1rem 1.25rem'] examples: [
'12px',
'12px 16px',
'1rem 1.25rem'
]
}, },
{ {
key: 'margin', key: 'margin',
description: 'CSS margin shorthand for external spacing.', description: 'CSS margin shorthand for external spacing.',
type: 'string', type: 'string',
example: '0', example: '0',
examples: ['0', '12px', '0 0 12px'] examples: [
'0',
'12px',
'0 0 12px'
]
}, },
{ {
key: 'border', key: 'border',
@@ -284,7 +358,11 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
description: 'CSS border radius shorthand.', description: 'CSS border radius shorthand.',
type: 'string', type: 'string',
example: '16px', example: '16px',
examples: ['12px', '16px', '999px'] examples: [
'12px',
'16px',
'999px'
]
}, },
{ {
key: 'backgroundColor', key: 'backgroundColor',
@@ -312,21 +390,33 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
description: 'CSS background-size value.', description: 'CSS background-size value.',
type: 'string', type: 'string',
example: 'cover', example: 'cover',
examples: ['cover', 'contain', 'auto 100%'] examples: [
'cover',
'contain',
'auto 100%'
]
}, },
{ {
key: 'backgroundPosition', key: 'backgroundPosition',
description: 'CSS background-position value.', description: 'CSS background-position value.',
type: 'string', type: 'string',
example: 'center', example: 'center',
examples: ['center', 'top left', '50% 20%'] examples: [
'center',
'top left',
'50% 20%'
]
}, },
{ {
key: 'backgroundRepeat', key: 'backgroundRepeat',
description: 'CSS background-repeat value.', description: 'CSS background-repeat value.',
type: 'string', type: 'string',
example: 'no-repeat', example: 'no-repeat',
examples: ['no-repeat', 'repeat', 'repeat-x'] examples: [
'no-repeat',
'repeat',
'repeat-x'
]
}, },
{ {
key: 'gradient', key: 'gradient',
@@ -429,4 +519,4 @@ export function createAnimationStarterDefinition(): ThemeDocument['animations'][
} }
} }
}; };
} }

View File

@@ -12,15 +12,58 @@ import {
getLayoutEditableThemeKeys getLayoutEditableThemeKeys
} from './theme.registry'; } from './theme.registry';
const TOP_LEVEL_KEYS = ['meta', 'tokens', 'layout', 'elements', 'animations'] as const; const TOP_LEVEL_KEYS = [
const META_KEYS = ['name', 'version', 'description'] as const; 'meta',
const TOKEN_GROUP_KEYS = ['colors', 'spacing', 'radii', 'effects'] as const; '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 LAYOUT_ENTRY_KEYS = ['container', 'grid'] as const;
const GRID_KEYS = ['x', 'y', 'w', 'h'] as const; const GRID_KEYS = [
const ANIMATION_KEYS = ['duration', 'easing', 'delay', 'iterationCount', 'fillMode', 'direction', 'keyframes'] as const; 'x',
const POSITION_VALUES = ['static', 'relative', 'absolute', 'sticky'] as const; 'y',
const FILL_MODE_VALUES = ['none', 'forwards', 'backwards', 'both'] as const; 'w',
const DIRECTION_VALUES = ['normal', 'reverse', 'alternate', 'alternate-reverse'] as const; '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_LINK_PROTOCOLS = ['http:', 'https:'] as const;
const SAFE_CLASS_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/; const SAFE_CLASS_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
const KEYFRAME_STEP_PATTERN = /^(from|to|(?:\d|[1-9]\d|100)%)$/; 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.`); errors.push(`${path}.${key} must be a valid absolute URL.`);
} }
} }
continue; continue;
} }
@@ -192,6 +236,7 @@ function validateElementStyles(value: unknown, path: string, errors: string[]):
if (validateString(fieldValue, `${path}.${key}`, errors) && !SAFE_CLASS_PATTERN.test(fieldValue)) { if (validateString(fieldValue, `${path}.${key}`, errors) && !SAFE_CLASS_PATTERN.test(fieldValue)) {
errors.push(`${path}.${key} must be a safe CSS class token.`); errors.push(`${path}.${key} must be a safe CSS class token.`);
} }
continue; continue;
} }
@@ -449,4 +494,4 @@ export function validateThemeDocument(input: unknown): ThemeValidationResult {
errors: [], errors: [],
value: normaliseThemeDocument(input as Partial<ThemeDocument>) value: normaliseThemeDocument(input as Partial<ThemeDocument>)
}; };
} }

View File

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

View File

@@ -169,6 +169,7 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
insert: nextValue insert: nextValue
} }
}); });
this.isApplyingExternalValue = false; this.isApplyingExternalValue = false;
}); });
@@ -203,6 +204,7 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
selection: EditorSelection.range(selectionStart, selectionEnd), selection: EditorSelection.range(selectionStart, selectionEnd),
effects: EditorView.scrollIntoView(selectionStart, { y: 'center' }) effects: EditorView.scrollIntoView(selectionStart, { y: 'center' })
}); });
this.editorView.focus(); 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 animationKeys = this.theme.knownAnimationClasses;
readonly layoutContainers = this.layoutSync.containers(); readonly layoutContainers = this.layoutSync.containers();
readonly themeEntries = this.registry.entries(); 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', key: 'editor',
label: 'JSON Editor', label: 'JSON Editor',
@@ -129,7 +129,8 @@ export class ThemeSettingsComponent {
return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable); return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable);
}); });
readonly filteredEntries = computed(() => { readonly filteredEntries = computed(() => {
const query = this.explorerQuery().trim().toLowerCase(); const query = this.explorerQuery().trim()
.toLowerCase();
if (!query) { if (!query) {
return this.mountedEntries(); return this.mountedEntries();
@@ -470,4 +471,4 @@ export class ThemeSettingsComponent {
return text.indexOf(`"${key}"`, sectionIndex); return text.indexOf(`"${key}"`, sectionIndex);
} }
} }

View File

@@ -4,7 +4,8 @@ import {
HostListener, HostListener,
effect, effect,
inject, inject,
input input,
OnDestroy
} from '@angular/core'; } from '@angular/core';
import { ExternalLinkService } from '../../../core/platform'; import { ExternalLinkService } from '../../../core/platform';
@@ -24,7 +25,7 @@ function looksLikeImageReference(value: string): boolean {
selector: '[appThemeNode]', selector: '[appThemeNode]',
standalone: true standalone: true
}) })
export class ThemeNodeDirective { export class ThemeNodeDirective implements OnDestroy {
readonly themeKey = input.required<string>({ alias: 'appThemeNode' }); readonly themeKey = input.required<string>({ alias: 'appThemeNode' });
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef); private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
@@ -245,4 +246,4 @@ export class ThemeNodeDirective {
iconTarget.style.backgroundImage = 'none'; iconTarget.style.backgroundImage = 'none';
iconTarget.textContent = ''; iconTarget.textContent = '';
} }
} }

View File

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

View File

@@ -10,4 +10,4 @@ export * from './domain/theme.schema';
export * from './domain/theme.validation'; export * from './domain/theme.validation';
export { ThemeNodeDirective } from './feature/theme-node.directive'; 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, isValid: true,
modifiedAt: file.modifiedAt, modifiedAt: file.modifiedAt,
themeName: result.value.meta.name, themeName: result.value.meta.name,
version: result.value.meta.version, version: result.value.meta.version
}; };
} catch (error) { } catch (error) {
return { return {
@@ -218,4 +218,4 @@ export class ThemeLibraryStorageService {
}; };
} }
} }
} }

View File

@@ -1,7 +1,4 @@
import { import { STORAGE_KEY_THEME_ACTIVE, STORAGE_KEY_THEME_DRAFT } from '../../../core/constants';
STORAGE_KEY_THEME_ACTIVE,
STORAGE_KEY_THEME_DRAFT
} from '../../../core/constants';
export interface ThemeStorageSnapshot { export interface ThemeStorageSnapshot {
activeText: string | null; activeText: string | null;
@@ -41,4 +38,4 @@ export function saveActiveThemeText(text: string): void {
export function saveDraftThemeText(text: string): void { export function saveDraftThemeText(text: string): void {
writeStoredThemeText(STORAGE_KEY_THEME_DRAFT, text); 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 { STORAGE_KEY_USER_VOLUMES } from '../../../core/constants';
import { ScreenShareFacade } from '../../../domains/screen-share'; import { ScreenShareFacade } from '../../../domains/screen-share';
import { User } from '../../../shared-kernel'; import { User } from '../../../shared-kernel';
import { import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
selectAllUsers,
selectCurrentUser
} from '../../../store/users/users.selectors';
import { VoiceConnectionFacade } from './voice-connection.facade'; import { VoiceConnectionFacade } from './voice-connection.facade';
export interface PlaybackOptions { export interface PlaybackOptions {

View File

@@ -282,6 +282,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
} }
}) })
); );
this.store.dispatch( this.store.dispatch(
UsersActions.updateCameraState({ UsersActions.updateCameraState({
userId: user.id, 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 { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
import { VoiceWorkspaceComponent } from '../voice-workspace/voice-workspace.component'; import { VoiceWorkspaceComponent } from '../voice-workspace/voice-workspace.component';
import { import { selectCurrentRoom, selectTextChannels } from '../../../store/rooms/rooms.selectors';
selectCurrentRoom,
selectTextChannels
} from '../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { VoiceWorkspaceService } from '../../../domains/voice-session'; import { VoiceWorkspaceService } from '../../../domains/voice-session';
import { import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
ThemeNodeDirective,
ThemeService
} from '../../../domains/theme';
@Component({ @Component({
selector: 'app-chat-room', selector: 'app-chat-room',

View File

@@ -440,8 +440,8 @@
[y]="userMenuY()" [y]="userMenuY()"
(closed)="closeUserMenu()" (closed)="closeUserMenu()"
> >
@if (isAdmin()) { @if (contextMenuUser(); as selectedUser) {
@if (contextMenuUser()?.role === 'member') { @if (canChangeUserRole(selectedUser) && selectedUser.role === 'member') {
<button <button
(click)="changeUserRole('moderator')" (click)="changeUserRole('moderator')"
class="context-menu-item" class="context-menu-item"
@@ -455,7 +455,7 @@
Promote to Admin Promote to Admin
</button> </button>
} }
@if (contextMenuUser()?.role === 'moderator') { @if (canChangeUserRole(selectedUser) && selectedUser.role === 'moderator') {
<button <button
(click)="changeUserRole('admin')" (click)="changeUserRole('admin')"
class="context-menu-item" class="context-menu-item"
@@ -469,7 +469,7 @@
Demote to Member Demote to Member
</button> </button>
} }
@if (contextMenuUser()?.role === 'admin') { @if (canChangeUserRole(selectedUser) && selectedUser.role === 'admin') {
<button <button
(click)="changeUserRole('member')" (click)="changeUserRole('member')"
class="context-menu-item" class="context-menu-item"
@@ -477,15 +477,20 @@
Demote to Member Demote to Member
</button> </button>
} }
<div class="context-menu-divider"></div> @if (canChangeUserRole(selectedUser) && canKickUser(selectedUser)) {
<button <div class="context-menu-divider"></div>
(click)="kickUserAction()" }
class="context-menu-item-danger" @if (canKickUser(selectedUser)) {
> <button
Kick User (click)="kickUserAction()"
</button> class="context-menu-item-danger"
} @else { >
<div class="context-menu-empty">No actions available</div> Kick User
</button>
}
@if (!canChangeUserRole(selectedUser) && !canKickUser(selectedUser)) {
<div class="context-menu-empty">No actions available</div>
}
} }
</app-context-menu> </app-context-menu>
} }

View File

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

View File

@@ -25,17 +25,11 @@ import {
import { Room, User } from '../../shared-kernel'; import { Room, User } from '../../shared-kernel';
import { VoiceSessionFacade } from '../../domains/voice-session'; import { VoiceSessionFacade } from '../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { import { selectCurrentUser, selectOnlineUsers } from '../../store/users/users.selectors';
selectCurrentUser,
selectOnlineUsers
} from '../../store/users/users.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions'; import { RoomsActions } from '../../store/rooms/rooms.actions';
import { DatabaseService } from '../../infrastructure/persistence'; import { DatabaseService } from '../../infrastructure/persistence';
import { NotificationsFacade } from '../../domains/notifications'; import { NotificationsFacade } from '../../domains/notifications';
import { import { type ServerInfo, ServerDirectoryFacade } from '../../domains/server-directory';
type ServerInfo,
ServerDirectoryFacade
} from '../../domains/server-directory';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers'; import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
import { import {
ConfirmDialogComponent, ConfirmDialogComponent,
@@ -89,7 +83,6 @@ export class ServersRailComponent {
voicePresenceByRoom = computed(() => { voicePresenceByRoom = computed(() => {
const presence: Record<string, number> = {}; const presence: Record<string, number> = {};
const seenByRoom = new Map<string, Set<string>>(); const seenByRoom = new Map<string, Set<string>>();
const addVoicePresence = (user: User | null | undefined): void => { const addVoicePresence = (user: User | null | undefined): void => {
if (!user) { if (!user) {
return; return;
@@ -103,6 +96,7 @@ export class ServersRailComponent {
} }
const userKey = user.oderId || user.id; const userKey = user.oderId || user.id;
let seenUsers = seenByRoom.get(roomId); let seenUsers = seenByRoom.get(roomId);
if (!seenUsers) { if (!seenUsers) {
@@ -344,15 +338,15 @@ export class ServersRailComponent {
this.joinPasswordError.set(null); this.joinPasswordError.set(null);
return this.serverDirectory.requestJoin({ return this.serverDirectory.requestJoin({
roomId: room.id, roomId: room.id,
userId: currentUserId, userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId, userPublicKey: currentUser?.oderId || currentUserId,
displayName: currentUser?.displayName || 'Anonymous', displayName: currentUser?.displayName || 'Anonymous',
password: password?.trim() || undefined password: password?.trim() || undefined
}, { }, {
sourceId: room.sourceId, sourceId: room.sourceId,
sourceUrl: room.sourceUrl sourceUrl: room.sourceUrl
}) })
.pipe( .pipe(
tap((response) => { tap((response) => {
this.closePasswordDialog(); this.closePasswordDialog();
@@ -395,6 +389,7 @@ export class ServersRailComponent {
...lookup, ...lookup,
[room.id]: true [room.id]: true
})); }));
this.bannedServerName.set(room.name); this.bannedServerName.set(room.name);
this.showBannedDialog.set(true); this.showBannedDialog.set(true);
return; return;

View File

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

View File

@@ -1,69 +1,80 @@
@if (server()) { @if (server()) {
<div class="space-y-3 max-w-xl"> <div class="space-y-3 max-w-3xl">
@if (members().length === 0) { @if (members().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No other members found for this server</p> <p class="text-sm text-muted-foreground text-center py-8">No other members found for this server</p>
} @else { } @else {
@for (member of members(); track member.oderId || member.id) { @for (member of members(); track member.oderId || member.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg"> <div class="space-y-3 rounded-lg bg-secondary/50 p-3">
<app-user-avatar <div class="flex items-center gap-3">
[name]="member.displayName || '?'" <app-user-avatar
size="sm" [name]="member.displayName || '?'"
/> size="sm"
<div class="flex-1 min-w-0"> />
<div class="flex items-center gap-1.5"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground truncate"> <div class="flex items-center gap-1.5">
{{ member.displayName }} <p class="truncate text-sm font-medium text-foreground">
</p> {{ member.displayName }}
@if (member.isOnline) { </p>
<span class="text-[10px] bg-emerald-500/20 text-emerald-400 px-1 py-0.5 rounded">Online</span> @if (member.isOnline) {
} <span class="rounded bg-emerald-500/20 px-1 py-0.5 text-[10px] text-emerald-400">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> <span class="rounded bg-primary/10 px-1 py-0.5 text-[10px] text-primary">{{ member.displayRoleName }}</span>
} @else if (member.role === 'admin') { </div>
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span> <p class="text-xs text-muted-foreground">{{ member.username }}</p>
} @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> </div>
</div>
@if (member.role !== 'host' && isAdmin()) {
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@if (canChangeRoles()) { @if (canKickMembers(member)) {
<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()) {
<button <button
(click)="kickMember(member)" (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" title="Kick"
> >
<ng-icon <ng-icon
name="lucideUserX" name="lucideUserX"
class="w-4 h-4" class="h-4 w-4"
/> />
</button> </button>
} }
@if (canBanMembers()) { @if (canBanMembers(member)) {
<button <button
(click)="banMember(member)" (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" title="Ban"
> >
<ng-icon <ng-icon
name="lucideBan" name="lucideBan"
class="w-4 h-4" class="h-4 w-4"
/> />
</button> </button>
} }
</div> </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> </div>
} }

View File

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

View File

@@ -1,129 +1,275 @@
@if (server()) { @if (normalizedServer(); as room) {
<div class="space-y-4 max-w-xl"> <div class="max-w-5xl space-y-4">
@if (!isAdmin()) { <div class="rounded-lg border border-border/60 bg-background/60 p-4">
<p class="text-xs text-muted-foreground mb-1">You are viewing this server's permissions. Only the server owner can make changes.</p> <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
<div class="space-y-2.5"> role permissions.
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg"> </p>
<div> @if (!canManageRoles()) {
<p class="text-sm font-medium text-foreground">Allow Voice Chat</p> <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>
<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>
</div> </div>
@if (isAdmin()) { <div class="grid gap-4 xl:grid-cols-[16rem,minmax(0,1fr)]">
<button <div class="space-y-3 rounded-lg bg-secondary/50 p-3">
(click)="savePermissions()" <div class="flex items-center justify-between gap-2">
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" <div>
[class.bg-green-600]="saveSuccess() === 'permissions'" <p class="text-sm font-medium text-foreground">Roles</p>
[class.hover:bg-green-600]="saveSuccess() === 'permissions'" <p class="text-xs text-muted-foreground">Higher roles appear first.</p>
> </div>
<ng-icon @if (canManageRoles()) {
name="lucideCheck" <button
class="w-4 h-4" type="button"
/> (click)="createRole()"
{{ saveSuccess() === 'permissions' ? 'Saved!' : 'Save Permissions' }} 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"
</button> >
} <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> </div>
} @else { } @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 */ /* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed,
effect,
inject, inject,
input, input
signal
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store'; 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 { 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({ @Component({
selector: 'app-permissions-settings', selector: 'app-permissions-settings',
@@ -24,7 +77,11 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideCheck lucideArrowDown,
lucideArrowUp,
lucideCheck,
lucidePlus,
lucideTrash2
}) })
], ],
templateUrl: './permissions-settings.component.html' templateUrl: './permissions-settings.component.html'
@@ -32,68 +89,305 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
export class PermissionsSettingsComponent { export class PermissionsSettingsComponent {
private store = inject(Store); private store = inject(Store);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null); server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false); isAdmin = input(false);
currentUser = this.store.selectSignal(selectCurrentUser);
allowVoice = true; permissionDefinitions = ROOM_PERMISSION_DEFINITIONS;
allowScreenShare = true; permissionStates: PermissionState[] = [
allowFileUploads = true; 'inherit',
slowModeInterval = '0'; 'allow',
adminsManageRooms = false; 'deny'
moderatorsManageRooms = false; ];
adminsManageIcon = false; normalizedServer = computed(() => {
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 {
const room = this.server(); 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; return;
const role = createCustomRoomRole('New Role', room.roles ?? []);
this.selectedRoleKey = role.id;
this.roleName = role.name;
this.roleColor = role.color ?? '#94a3b8';
this.store.dispatch( this.store.dispatch(
RoomsActions.updateRoomPermissions({ RoomsActions.updateRoomAccessControl({
roomId: room.id, roomId: room.id,
permissions: { changes: { roles: [...(room.roles ?? []), role] }
allowVoice: this.allowVoice, })
allowScreenShare: this.allowScreenShare, );
allowFileUploads: this.allowFileUploads, }
slowModeInterval: parseInt(this.slowModeInterval, 10),
adminsManageRooms: this.adminsManageRooms, saveRoleDetails(): void {
moderatorsManageRooms: this.moderatorsManageRooms, const room = this.normalizedServer();
adminsManageIcon: this.adminsManageIcon, const role = this.selectedRole();
moderatorsManageIcon: this.moderatorsManageIcon
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 { updateSlowMode(intervalValue: string): void {
this.saveSuccess.set(key); const room = this.normalizedServer();
if (this.saveTimeout) if (!room || !this.canManageServer())
clearTimeout(this.saveTimeout); 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 <app-permissions-settings
#permissionsComp #permissionsComp
[server]="selectedServer()" [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 { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors';
import { Room, UserRole } from '../../../shared-kernel'; 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 { 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 { GeneralSettingsComponent } from './general-settings/general-settings.component';
import { NetworkSettingsComponent } from './network-settings/network-settings.component'; import { NetworkSettingsComponent } from './network-settings/network-settings.component';
@@ -153,9 +153,16 @@ export class SettingsModalComponent {
return []; return [];
return this.savedRooms().filter((room) => { 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) if (!server || !user)
return null; return null;
return this.getUserRoleForRoom( return resolveLegacyRole(this.currentRoom()?.id === server.id ? this.currentRoom() ?? server : server, user);
server,
user.id,
user.oderId,
this.currentRoom()?.id === server.id ? user.role : null
);
}); });
canAccessSelectedServer = computed(() => { 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(() => { 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(() => { 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(() => { 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') @HostListener('document:keydown.escape')
onEscapeKey(): void { onEscapeKey(): void {
if (this.showThirdPartyLicenses()) { if (this.showThirdPartyLicenses()) {

View File

@@ -1,7 +1,4 @@
import { import { STORAGE_KEY_GENERAL_SETTINGS, STORAGE_KEY_LAST_VIEWED_CHAT } from '../../core/constants';
STORAGE_KEY_GENERAL_SETTINGS,
STORAGE_KEY_LAST_VIEWED_CHAT
} from '../../core/constants';
export interface GeneralSettings { export interface GeneralSettings {
reopenLastViewedChat: boolean; reopenLastViewedChat: boolean;

Some files were not shown because too many files have changed in this diff Show More