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];
if (state === 'allow') {
return true;
} }
const membersByKey = new Map<string, RoomMemberRecord>(); if (state === 'deny') {
return false;
for (const rawMember of rawMembers) {
if (!rawMember || typeof rawMember !== 'object') {
continue;
} }
const member = normalizeRoomMember(rawMember as Record<string, unknown>, now); return fallbackValue;
};
if (!member) { return {
continue; allowVoice: toBoolean(everyoneRole, 'joinVoice', true),
} allowScreenShare: toBoolean(everyoneRole, 'shareScreen', true),
allowFileUploads: toBoolean(everyoneRole, 'uploadFiles', true),
const key = memberKey(member); adminsManageRooms: adminRole?.permissions?.manageChannels === 'allow',
moderatorsManageRooms: moderatorRole?.permissions?.manageChannels === 'allow',
membersByKey.set(key, mergeRoomMembers(membersByKey.get(key), member)); adminsManageIcon: adminRole?.permissions?.manageIcon === 'allow',
} moderatorsManageIcon: moderatorRole?.permissions?.manageIcon === 'allow',
slowModeInterval
return Array.from(membersByKey.values()).sort(compareRoomMembers); };
} }
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

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

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

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

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

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

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

@@ -7,8 +7,9 @@ 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` |
| **access-control** | Role, permission, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()` |
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` | | **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | | **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
@@ -23,6 +24,7 @@ infrastructure adapters and UI.
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)
@@ -67,7 +69,7 @@ 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/` |

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 {
@if (requiresRichMarkdown(msg.content)) {
@defer {
<div class="chat-markdown mt-1 break-words"> <div class="chat-markdown mt-1 break-words">
<remark <app-chat-message-markdown [content]="msg.content" />
[markdown]="msg.content" </div>
[processor]="$any(remarkProcessor)" } @placeholder {
> <div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
<ng-template }
[remarkTemplate]="'code'"
let-node
>
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
<remark-mermaid [code]="getMermaidCode(node.value)" />
} @else { } @else {
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre> <div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
} }
</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', : [
{ id: 'general',
name: 'general', name: 'general',
type: 'text', type: 'text',
position: 0 }]; 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,9 +73,7 @@ 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`)
.pipe(
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())), map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
catchError((error) => { catchError((error) => {
console.error('Failed to get servers:', error); console.error('Failed to get servers:', error);
@@ -79,9 +83,7 @@ export class ServerDirectoryApiService {
} }
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}`)
.pipe(
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))), map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
catchError((error) => { catchError((error) => {
console.error('Failed to get server:', error); console.error('Failed to get server:', error);
@@ -94,9 +96,7 @@ export class ServerDirectoryApiService {
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)
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to register server:', error); console.error('Failed to register server:', error);
return throwError(() => error); return throwError(() => error);
@@ -113,9 +113,7 @@ 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)
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to update server:', error); console.error('Failed to update server:', error);
return throwError(() => error); return throwError(() => error);
@@ -124,9 +122,7 @@ export class ServerDirectoryApiService {
} }
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}`)
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to unregister server:', error); console.error('Failed to unregister server:', error);
return throwError(() => error); return throwError(() => error);
@@ -135,9 +131,7 @@ export class ServerDirectoryApiService {
} }
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`)
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to get server users:', error); console.error('Failed to get server users:', error);
return of([]); return of([]);
@@ -145,16 +139,8 @@ export class ServerDirectoryApiService {
); );
} }
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
): Observable<ServerJoinAccessResponse> {
return this.http
.post<ServerJoinAccessResponse>(
`${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`,
request
)
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to send join request:', error); console.error('Failed to send join request:', error);
return throwError(() => error); return throwError(() => error);
@@ -162,14 +148,8 @@ export class ServerDirectoryApiService {
); );
} }
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,
selector?: ServerSourceSelector
): Observable<ServerInviteInfo> {
return this.http
.post<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request)
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to create invite:', error); console.error('Failed to create invite:', error);
return throwError(() => error); return throwError(() => error);
@@ -178,9 +158,7 @@ export class ServerDirectoryApiService {
} }
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}`)
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to get invite:', error); console.error('Failed to get invite:', error);
return throwError(() => error); return throwError(() => error);
@@ -188,14 +166,8 @@ export class ServerDirectoryApiService {
); );
} }
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,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request)
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to kick server member:', error); console.error('Failed to kick server member:', error);
return throwError(() => error); return throwError(() => error);
@@ -203,14 +175,8 @@ export class ServerDirectoryApiService {
); );
} }
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,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request)
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to ban server member:', error); console.error('Failed to ban server member:', error);
return throwError(() => error); return throwError(() => error);
@@ -218,14 +184,8 @@ export class ServerDirectoryApiService {
); );
} }
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,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request)
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to unban server member:', error); console.error('Failed to unban server member:', error);
return throwError(() => error); return throwError(() => error);
@@ -234,9 +194,7 @@ export class ServerDirectoryApiService {
} }
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 })
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to notify leave:', error); console.error('Failed to notify leave:', error);
return of(undefined); return of(undefined);
@@ -245,9 +203,7 @@ export class ServerDirectoryApiService {
} }
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 })
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to update user count:', error); console.error('Failed to update user count:', error);
return of(undefined); return of(undefined);
@@ -256,9 +212,7 @@ export class ServerDirectoryApiService {
} }
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`, {})
.pipe(
catchError((error) => { catchError((error) => {
console.error('Failed to send heartbeat:', error); console.error('Failed to send heartbeat:', error);
return of(undefined); return of(undefined);
@@ -274,24 +228,14 @@ 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 })
.pipe(
map((response) => this.normalizeServerList(response, source)), map((response) => this.normalizeServerList(response, source)),
catchError((error) => { catchError((error) => {
console.error('Failed to search servers:', error); console.error('Failed to search servers:', error);
@@ -301,31 +245,23 @@ export class ServerDirectoryApiService {
} }
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`)
.pipe(
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())), map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
catchError(() => of([])) catchError(() => of([]))
); );
@@ -333,9 +269,7 @@ export class ServerDirectoryApiService {
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`)
.pipe(
map((response) => this.normalizeServerList(response, endpoint)), map((response) => this.normalizeServerList(response, endpoint)),
catchError(() => of([] as ServerInfo[])) catchError(() => of([] as ServerInfo[]))
) )
@@ -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;

View File

@@ -1,4 +1,8 @@
import { Injectable, computed, inject } from '@angular/core'; import {
Injectable,
computed,
inject
} from '@angular/core';
import { import {
ThemeContainerKey, ThemeContainerKey,

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

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,

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

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,

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

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[] = [
{ {

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

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

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

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

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

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 {

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;

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,16 +477,21 @@
Demote to Member Demote to Member
</button> </button>
} }
@if (canChangeUserRole(selectedUser) && canKickUser(selectedUser)) {
<div class="context-menu-divider"></div> <div class="context-menu-divider"></div>
}
@if (canKickUser(selectedUser)) {
<button <button
(click)="kickUserAction()" (click)="kickUserAction()"
class="context-menu-item-danger" class="context-menu-item-danger"
> >
Kick User Kick User
</button> </button>
} @else { }
@if (!canChangeUserRole(selectedUser) && !canKickUser(selectedUser)) {
<div class="context-menu-empty">No actions available</div> <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,29 +469,28 @@ export class RoomsSidePanelComponent {
} }
} }
joinVoice(roomId: string) { private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
const room = this.currentRoom(); if (!room || !current?.voiceState?.isConnected || current.voiceState.roomId !== roomId || current.voiceState.serverId !== room.id) {
const current = this.currentUser(); return false;
}
if (
room
&& current?.voiceState?.isConnected
&& current.voiceState.roomId === roomId
&& current.voiceState.serverId === room.id
) {
this.voiceWorkspace.open(null, { connectRemoteShares: true }); this.voiceWorkspace.open(null, { connectRemoteShares: true });
return; return true;
} }
if (room && room.permissions && room.permissions.allowVoice === false) { private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
return; return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
} }
if (!room) private prepareCrossServerVoiceJoin(room: Room, current: User | null): boolean {
return; if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
return true;
}
if (this.voiceConnection.isVoiceConnected()) {
return false;
}
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
if (!this.voiceConnection.isVoiceConnected()) {
if (current.id) { if (current.id) {
this.store.dispatch( this.store.dispatch(
UsersActions.updateVoiceState({ UsersActions.updateVoiceState({
@@ -519,15 +505,33 @@ export class RoomsSidePanelComponent {
}) })
); );
} }
} else {
return true;
}
private enableVoiceForJoin(room: Room, current: User | null, roomId: string): Promise<void> {
const isSwitchingChannels = !!current?.voiceState?.isConnected && current.voiceState.serverId === room.id && current.voiceState.roomId !== roomId;
return isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice().then(() => undefined);
}
joinVoice(roomId: string) {
const room = this.currentRoom();
const current = this.currentUser();
if (this.openExistingVoiceWorkspace(room, current ?? null, roomId)) {
return; return;
} }
if (!room || !this.canJoinRequestedVoiceRoom(room, current ?? null, roomId)) {
return;
} }
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId; if (!this.prepareCrossServerVoiceJoin(room, current ?? null)) {
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice(); return;
}
enableVoicePromise this.enableVoiceForJoin(room, current ?? null, roomId)
.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(
UsersActions.updateVoiceState({
userId: targetUser.id, userId: targetUser.id,
voiceState: movedVoiceState 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,7 +837,8 @@ export class RoomsSidePanelComponent {
return false; return false;
} }
const stream = this.getPeerKeysForUser(user, userId) const stream =
this.getPeerKeysForUser(user, userId)
.map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey)) .map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey))
.find((candidate) => this.hasActiveVideoStream(candidate)) || null; .find((candidate) => this.hasActiveVideoStream(candidate)) || null;
@@ -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) {
@@ -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">
<div class="flex items-center gap-3">
<app-user-avatar <app-user-avatar
[name]="member.displayName || '?'" [name]="member.displayName || '?'"
size="sm" size="sm"
/> />
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-foreground truncate"> <p class="truncate text-sm font-medium text-foreground">
{{ member.displayName }} {{ member.displayName }}
</p> </p>
@if (member.isOnline) { @if (member.isOnline) {
<span class="text-[10px] bg-emerald-500/20 text-emerald-400 px-1 py-0.5 rounded">Online</span> <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>
} @else if (member.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
} @else if (member.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
} }
<span class="rounded bg-primary/10 px-1 py-0.5 text-[10px] text-primary">{{ member.displayRoleName }}</span>
</div> </div>
<p class="text-xs text-muted-foreground">{{ member.username }}</p>
</div> </div>
@if (member.role !== 'host' && isAdmin()) {
<div class="flex items-center gap-1"> <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
role permissions.
</p>
@if (!canManageRoles()) {
<p class="mt-2 text-xs text-muted-foreground">You can inspect this server's access model, but only members with Manage Roles can edit it.</p>
} }
<div class="space-y-2.5"> </div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div class="grid gap-4 xl:grid-cols-[16rem,minmax(0,1fr)]">
<div class="space-y-3 rounded-lg bg-secondary/50 p-3">
<div class="flex items-center justify-between gap-2">
<div> <div>
<p class="text-sm font-medium text-foreground">Allow Voice Chat</p> <p class="text-sm font-medium text-foreground">Roles</p>
<p class="text-xs text-muted-foreground">Users can join voice channels</p> <p class="text-xs text-muted-foreground">Higher roles appear first.</p>
</div> </div>
<input @if (canManageRoles()) {
type="checkbox" <button
[(ngModel)]="allowVoice" type="button"
[disabled]="!isAdmin()" (click)="createRole()"
class="w-4 h-4 accent-primary" class="inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground transition-colors hover:bg-background/80"
>
<ng-icon
name="lucidePlus"
class="h-3.5 w-3.5"
/> />
<span>Role</span>
</button>
}
</div> </div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div> <div class="space-y-2">
<p class="text-sm font-medium text-foreground">Allow Screen Share</p> @for (role of roles(); track role.id) {
<p class="text-xs text-muted-foreground">Users can share their screen</p> <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>
<input
type="checkbox"
[(ngModel)]="allowScreenShare"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div> </div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div> <div class="space-y-4">
<p class="text-sm font-medium text-foreground">Allow File Uploads</p> <div class="rounded-lg bg-secondary/50 p-4">
<p class="text-xs text-muted-foreground">Users can upload files</p> <div class="flex items-center justify-between gap-4">
</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> <div>
<p class="text-sm font-medium text-foreground">Slow Mode</p> <p class="text-sm font-medium text-foreground">Slow Mode</p>
<p class="text-xs text-muted-foreground">Limit message frequency</p> <p class="text-xs text-muted-foreground">Sets the minimum delay between messages for everyone in the server.</p>
</div> </div>
<select <select
[(ngModel)]="slowModeInterval" [ngModel]="slowModeValue(room.slowModeInterval)"
[disabled]="!isAdmin()" (ngModelChange)="updateSlowMode($event)"
class="px-3 py-1 bg-secondary rounded border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary" [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="0">Off</option>
<option value="5">5 seconds</option> <option value="5">5 seconds</option>
<option value="10">10 seconds</option> <option value="10">10 seconds</option>
<option value="30">30 seconds</option> <option value="30">30 seconds</option>
<option value="60">1 minute</option> <option value="60">1 minute</option>
<option value="120">2 minutes</option>
</select> </select>
</div> </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()) { @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 <button
(click)="savePermissions()" type="button"
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" (click)="saveRoleDetails()"
[class.bg-green-600]="saveSuccess() === 'permissions'" [disabled]="!canEditSelectedRoleMetadata()"
[class.hover:bg-green-600]="saveSuccess() === 'permissions'" 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 <ng-icon
name="lucideCheck" name="lucideCheck"
class="w-4 h-4" class="h-4 w-4"
/> />
{{ saveSuccess() === 'permissions' ? 'Saved!' : 'Save Permissions' }} <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> </button>
} }
</div> </div>
@if (role.isSystem) {
<p class="text-xs text-muted-foreground">
System roles can still have their permissions tuned, but their name, color, and membership in the base hierarchy stay fixed.
</p>
}
</div>
<div class="space-y-3 rounded-lg bg-secondary/50 p-4">
<div>
<p class="text-sm font-medium text-foreground">Base Permissions</p>
<p class="text-xs text-muted-foreground">These defaults apply everywhere unless a channel override changes them.</p>
</div>
<div class="space-y-2">
@for (permission of permissionDefinitions; track permission.key) {
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-background/60 p-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">{{ permission.label }}</p>
<p class="text-xs text-muted-foreground">{{ permission.description }}</p>
</div>
<select
[ngModel]="permissionState(permission.key)"
(ngModelChange)="setSelectedRolePermission(permission.key, coercePermissionState($event))"
[disabled]="!canEditSelectedRole()"
class="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (state of permissionStates; track state) {
<option [value]="state">
{{ state === 'inherit' ? 'Inherit' : state === 'allow' ? 'Allow' : 'Deny' }}
</option>
}
</select>
</div>
}
</div>
</div>
<div class="space-y-3 rounded-lg bg-secondary/50 p-4">
<div>
<p class="text-sm font-medium text-foreground">Channel Overrides</p>
<p class="text-xs text-muted-foreground">
Override the selected role inside a specific channel without changing the server-wide default.
</p>
</div>
@if (channels().length === 0) {
<p class="text-sm text-muted-foreground">This server has no channels yet.</p>
} @else {
<div class="flex items-center gap-3">
<label class="min-w-0 flex-1 space-y-1">
<span class="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Channel</span>
<select
[ngModel]="selectedChannelKey"
(ngModelChange)="selectChannel($event)"
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (channel of channels(); track channel.id) {
<option [value]="channel.id">{{ channel.name }} ({{ channel.type | titlecase }})</option>
}
</select>
</label>
</div>
<div class="space-y-2">
@for (permission of permissionDefinitions; track permission.key) {
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-background/60 p-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">{{ permission.label }}</p>
<p class="text-xs text-muted-foreground">{{ permission.description }}</p>
</div>
<select
[ngModel]="channelOverrideState(permission.key)"
(ngModelChange)="setChannelOverride(permission.key, coercePermissionState($event))"
[disabled]="!canEditSelectedRole()"
class="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (state of permissionStates; track state) {
<option [value]="state">
{{ state === 'inherit' ? 'Inherit' : state === 'allow' ? 'Allow' : 'Deny' }}
</option>
}
</select>
</div>
}
</div>
}
</div>
}
</div>
</div>
</div>
} @else { } @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;

View File

@@ -0,0 +1,44 @@
export const ROOM_PERMISSION_KEYS = [
'manageServer',
'manageRoles',
'manageChannels',
'manageIcon',
'kickMembers',
'banMembers',
'manageBans',
'deleteMessages',
'joinVoice',
'shareScreen',
'uploadFiles'
] as const;
export type RoomPermissionKey = typeof ROOM_PERMISSION_KEYS[number];
export type PermissionState = 'allow' | 'deny' | 'inherit';
export type ChannelPermissionTargetType = 'role' | 'user';
export type RoomPermissionMatrix = Partial<Record<RoomPermissionKey, PermissionState>>;
export interface RoomRole {
id: string;
name: string;
color?: string;
position: number;
isSystem?: boolean;
permissions?: RoomPermissionMatrix;
}
export interface RoomRoleAssignment {
userId: string;
oderId?: string;
roleIds: string[];
}
export interface ChannelPermissionOverride {
channelId: string;
targetType: ChannelPermissionTargetType;
targetId: string;
permission: RoomPermissionKey;
value: PermissionState;
}

View File

@@ -1,5 +1,6 @@
export * from './user.models'; export * from './user.models';
export * from './room.models'; export * from './room.models';
export * from './access-control.models';
export * from './message.models'; export * from './message.models';
export * from './moderation.models'; export * from './moderation.models';
export * from './voice-state.models'; export * from './voice-state.models';

View File

@@ -1,4 +1,9 @@
import type { RoomMember } from './user.models'; import type { RoomMember } from './user.models';
import type {
ChannelPermissionOverride,
RoomRole,
RoomRoleAssignment
} from './access-control.models';
export type ChannelType = 'text' | 'voice'; export type ChannelType = 'text' | 'voice';
@@ -23,9 +28,13 @@ export interface Room {
maxUsers?: number; maxUsers?: number;
icon?: string; icon?: string;
iconUpdatedAt?: number; iconUpdatedAt?: number;
slowModeInterval?: number;
permissions?: RoomPermissions; permissions?: RoomPermissions;
channels?: Channel[]; channels?: Channel[];
members?: RoomMember[]; members?: RoomMember[];
roles?: RoomRole[];
roleAssignments?: RoomRoleAssignment[];
channelPermissions?: ChannelPermissionOverride[];
sourceId?: string; sourceId?: string;
sourceName?: string; sourceName?: string;
sourceUrl?: string; sourceUrl?: string;

View File

@@ -1,4 +1,8 @@
import type { CameraState, VoiceState, ScreenShareState } from './voice-state.models'; import type {
CameraState,
VoiceState,
ScreenShareState
} from './voice-state.models';
export type UserStatus = 'online' | 'away' | 'busy' | 'offline'; export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
@@ -29,6 +33,7 @@ export interface RoomMember {
displayName: string; displayName: string;
avatarUrl?: string; avatarUrl?: string;
role: UserRole; role: UserRole;
roleIds?: string[];
joinedAt: number; joinedAt: number;
lastSeenAt: number; lastSeenAt: number;
} }

View File

@@ -44,6 +44,7 @@ import {
} from '../../shared-kernel'; } from '../../shared-kernel';
import { hydrateMessages } from './messages.helpers'; import { hydrateMessages } from './messages.helpers';
import { canEditMessage } from '../../domains/chat/domain/message.rules'; import { canEditMessage } from '../../domains/chat/domain/message.rules';
import { resolveRoomPermission } from '../../domains/access-control';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers'; import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
@Injectable() @Injectable()
@@ -244,16 +245,17 @@ export class MessagesEffects {
adminDeleteMessage$ = createEffect(() => adminDeleteMessage$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(MessagesActions.adminDeleteMessage), ofType(MessagesActions.adminDeleteMessage),
withLatestFrom(this.store.select(selectCurrentUser)), withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
mergeMap(([{ messageId }, currentUser]) => { mergeMap(([
{ messageId },
currentUser,
currentRoom
]) => {
if (!currentUser) { if (!currentUser) {
return of(MessagesActions.deleteMessageFailure({ error: 'Not logged in' })); return of(MessagesActions.deleteMessageFailure({ error: 'Not logged in' }));
} }
const hasPermission = const hasPermission = !!currentRoom && resolveRoomPermission(currentRoom, currentUser, 'deleteMessages');
currentUser.role === 'host' ||
currentUser.role === 'admin' ||
currentUser.role === 'moderator';
if (!hasPermission) { if (!hasPermission) {
return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' })); return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' }));

View File

@@ -1,7 +1,4 @@
import { import { Channel, ChannelType } from '../../shared-kernel';
Channel,
ChannelType
} from '../../shared-kernel';
export function normalizeChannelName(name: string): string { export function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' '); return name.trim().replace(/\s+/g, ' ');

View File

@@ -16,6 +16,16 @@ function fallbackUsername(member: Partial<RoomMember>): string {
return base || member.oderId || member.id || 'user'; return base || member.oderId || member.id || 'user';
} }
function normalizeRoleIds(roleIds: readonly string[] | undefined): string[] | undefined {
if (!Array.isArray(roleIds)) {
return undefined;
}
const normalized = Array.from(new Set(roleIds.filter((roleId): roleId is string => typeof roleId === 'string' && roleId.trim().length > 0).map((roleId) => roleId.trim())));
return normalized.length > 0 ? normalized : undefined;
}
function normalizeMember(member: RoomMember, now = Date.now()): RoomMember { function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
const key = getRoomMemberKey(member); const key = getRoomMemberKey(member);
const lastSeenAt = const lastSeenAt =
@@ -36,6 +46,7 @@ function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
displayName: fallbackDisplayName(member), displayName: fallbackDisplayName(member),
avatarUrl: member.avatarUrl || undefined, avatarUrl: member.avatarUrl || undefined,
role: member.role || 'member', role: member.role || 'member',
roleIds: normalizeRoleIds(member.roleIds),
joinedAt, joinedAt,
lastSeenAt lastSeenAt
}; };
@@ -93,6 +104,9 @@ function mergeMembers(
? (normalizedIncoming.avatarUrl || normalizedExisting.avatarUrl) ? (normalizedIncoming.avatarUrl || normalizedExisting.avatarUrl)
: (normalizedExisting.avatarUrl || normalizedIncoming.avatarUrl), : (normalizedExisting.avatarUrl || normalizedIncoming.avatarUrl),
role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming), role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming),
roleIds: preferIncoming
? (normalizedIncoming.roleIds || normalizedExisting.roleIds)
: (normalizedExisting.roleIds || normalizedIncoming.roleIds),
joinedAt: Math.min(normalizedExisting.joinedAt, normalizedIncoming.joinedAt), joinedAt: Math.min(normalizedExisting.joinedAt, normalizedIncoming.joinedAt),
lastSeenAt: Math.max(normalizedExisting.lastSeenAt, normalizedIncoming.lastSeenAt) lastSeenAt: Math.max(normalizedExisting.lastSeenAt, normalizedIncoming.lastSeenAt)
}; };

View File

@@ -58,6 +58,10 @@ export const RoomsActions = createActionGroup({
'Update Room Settings Failure': props<{ error: string }>(), 'Update Room Settings Failure': props<{ error: string }>(),
'Update Room Permissions': props<{ roomId: string; permissions: Partial<RoomPermissions> }>(), 'Update Room Permissions': props<{ roomId: string; permissions: Partial<RoomPermissions> }>(),
'Update Room Access Control': props<{
roomId: string;
changes: Pick<Partial<Room>, 'roles' | 'roleAssignments' | 'channelPermissions' | 'slowModeInterval'>;
}>(),
'Update Server Icon': props<{ roomId: string; icon: string }>(), 'Update Server Icon': props<{ roomId: string; icon: string }>(),
'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(), 'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),

View File

@@ -2,19 +2,13 @@
/* eslint-disable id-length */ /* eslint-disable id-length */
/* eslint-disable @typescript-eslint/no-unused-vars,, complexity */ /* eslint-disable @typescript-eslint/no-unused-vars,, complexity */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { import { NavigationEnd, Router } from '@angular/router';
NavigationEnd,
Router
} from '@angular/router';
import { import {
Actions, Actions,
createEffect, createEffect,
ofType ofType
} from '@ngrx/effects'; } from '@ngrx/effects';
import { import { Action, Store } from '@ngrx/store';
Action,
Store
} from '@ngrx/store';
import { import {
of, of,
from, from,
@@ -53,6 +47,12 @@ import {
type ServerSourceSelector, type ServerSourceSelector,
ServerDirectoryFacade ServerDirectoryFacade
} from '../../domains/server-directory'; } from '../../domains/server-directory';
import {
normalizeRoomAccessControl,
resolveLegacyRole,
resolveRoomPermission,
withLegacyRoomPermissions
} from '../../domains/access-control';
import { import {
ChatEvent, ChatEvent,
Room, Room,
@@ -392,6 +392,10 @@ export class RoomsEffects {
...room, ...room,
isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate, isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate,
channels: resolveRoomChannels(room.channels, serverInfo?.channels), channels: resolveRoomChannels(room.channels, serverInfo?.channels),
slowModeInterval: serverInfo?.slowModeInterval ?? room.slowModeInterval,
roles: serverInfo?.roles ?? room.roles,
roleAssignments: serverInfo?.roleAssignments ?? room.roleAssignments,
channelPermissions: serverInfo?.channelPermissions ?? room.channelPermissions,
sourceId: serverInfo?.sourceId ?? room.sourceId, sourceId: serverInfo?.sourceId ?? room.sourceId,
sourceName: serverInfo?.sourceName ?? room.sourceName, sourceName: serverInfo?.sourceName ?? room.sourceName,
sourceUrl: serverInfo?.sourceUrl ?? room.sourceUrl, sourceUrl: serverInfo?.sourceUrl ?? room.sourceUrl,
@@ -406,6 +410,10 @@ export class RoomsEffects {
sourceName: resolvedRoom.sourceName, sourceName: resolvedRoom.sourceName,
sourceUrl: resolvedRoom.sourceUrl, sourceUrl: resolvedRoom.sourceUrl,
channels: resolvedRoom.channels, channels: resolvedRoom.channels,
slowModeInterval: resolvedRoom.slowModeInterval,
roles: resolvedRoom.roles,
roleAssignments: resolvedRoom.roleAssignments,
channelPermissions: resolvedRoom.channelPermissions,
hasPassword: resolvedRoom.hasPassword, hasPassword: resolvedRoom.hasPassword,
isPrivate: resolvedRoom.isPrivate isPrivate: resolvedRoom.isPrivate
}); });
@@ -426,6 +434,10 @@ export class RoomsEffects {
userCount: 1, userCount: 1,
maxUsers: 50, maxUsers: 50,
channels: resolveRoomChannels(undefined, serverInfo.channels), channels: resolveRoomChannels(undefined, serverInfo.channels),
slowModeInterval: serverInfo.slowModeInterval,
roles: serverInfo.roles,
roleAssignments: serverInfo.roleAssignments,
channelPermissions: serverInfo.channelPermissions,
sourceId: serverInfo.sourceId, sourceId: serverInfo.sourceId,
sourceName: serverInfo.sourceName, sourceName: serverInfo.sourceName,
sourceUrl: serverInfo.sourceUrl sourceUrl: serverInfo.sourceUrl
@@ -451,6 +463,10 @@ export class RoomsEffects {
userCount: serverData.userCount, userCount: serverData.userCount,
maxUsers: serverData.maxUsers, maxUsers: serverData.maxUsers,
channels: resolveRoomChannels(undefined, serverData.channels), channels: resolveRoomChannels(undefined, serverData.channels),
slowModeInterval: serverData.slowModeInterval,
roles: serverData.roles,
roleAssignments: serverData.roleAssignments,
channelPermissions: serverData.channelPermissions,
sourceId: serverData.sourceId, sourceId: serverData.sourceId,
sourceName: serverData.sourceName, sourceName: serverData.sourceName,
sourceUrl: serverData.sourceUrl sourceUrl: serverData.sourceUrl
@@ -504,10 +520,7 @@ export class RoomsEffects {
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess), ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
withLatestFrom(this.store.select(selectCurrentUser)), withLatestFrom(this.store.select(selectCurrentUser)),
tap(([ tap(([{ room }, currentUser]) => {
{ room },
currentUser
]) => {
if (!currentUser) { if (!currentUser) {
return; return;
} }
@@ -621,6 +634,10 @@ export class RoomsEffects {
isPrivate: serverData.isPrivate, isPrivate: serverData.isPrivate,
maxUsers: serverData.maxUsers, maxUsers: serverData.maxUsers,
channels: resolveRoomChannels(room.channels, serverData.channels), channels: resolveRoomChannels(room.channels, serverData.channels),
slowModeInterval: serverData.slowModeInterval ?? room.slowModeInterval,
roles: serverData.roles ?? room.roles,
roleAssignments: serverData.roleAssignments ?? room.roleAssignments,
channelPermissions: serverData.channelPermissions ?? room.channelPermissions,
sourceId: serverData.sourceId ?? room.sourceId, sourceId: serverData.sourceId ?? room.sourceId,
sourceName: serverData.sourceName ?? room.sourceName, sourceName: serverData.sourceName ?? room.sourceName,
sourceUrl: serverData.sourceUrl ?? room.sourceUrl sourceUrl: serverData.sourceUrl ?? room.sourceUrl
@@ -856,7 +873,7 @@ export class RoomsEffects {
const currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom); const currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom);
const isOwner = currentUserRole === 'host'; const isOwner = currentUserRole === 'host';
const canManageRoom = currentUserRole === 'host' || currentUserRole === 'admin'; const canManageRoom = isOwner || resolveRoomPermission(room, currentUser, 'manageServer');
if (!canManageRoom) { if (!canManageRoom) {
return of( return of(
@@ -949,7 +966,10 @@ export class RoomsEffects {
this.store.select(selectCurrentUser), this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom) this.store.select(selectCurrentRoom)
), ),
tap(([, currentUser, currentRoom]) => { tap(([
, currentUser,
currentRoom
]) => {
if (!currentUser || !currentRoom) { if (!currentUser || !currentRoom) {
return; return;
} }
@@ -1008,27 +1028,93 @@ export class RoomsEffects {
if (!room) if (!room)
return EMPTY; return EMPTY;
const isOwner = const nextRoom = withLegacyRoomPermissions(room, permissions);
room.hostId === currentUser.id ||
room.hostId === currentUser.oderId ||
(currentRoom?.id === room.id && currentUser.role === 'host');
if (!isOwner) return of(RoomsActions.updateRoomAccessControl({
roomId: room.id,
changes: {
roles: nextRoom.roles,
roleAssignments: nextRoom.roleAssignments,
channelPermissions: nextRoom.channelPermissions,
slowModeInterval: nextRoom.slowModeInterval
}
}));
})
)
);
updateRoomAccessControl$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.updateRoomAccessControl),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
{ roomId, changes },
currentUser,
currentRoom,
savedRooms
]) => {
if (!currentUser)
return EMPTY; return EMPTY;
const updated: Partial<Room> = { const room = this.resolveRoom(roomId, currentRoom, savedRooms);
permissions: { ...(room.permissions || {}),
...permissions } as RoomPermissions if (!room)
return EMPTY;
const requiresRoleManagement = !!changes.roles || !!changes.roleAssignments || !!changes.channelPermissions;
const requiresServerManagement = typeof changes.slowModeInterval === 'number';
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
const canManageRoles = isOwner || resolveRoomPermission(room, currentUser, 'manageRoles');
const canManageServer = isOwner || resolveRoomPermission(room, currentUser, 'manageServer');
if ((requiresRoleManagement && !canManageRoles) || (requiresServerManagement && !canManageServer)) {
return EMPTY;
}
const nextRoom = normalizeRoomAccessControl({
...room,
...changes
});
const nextChanges: Partial<Room> = {
roles: nextRoom.roles,
roleAssignments: nextRoom.roleAssignments,
channelPermissions: nextRoom.channelPermissions,
slowModeInterval: nextRoom.slowModeInterval,
permissions: nextRoom.permissions,
members: nextRoom.members
}; };
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'room-permissions-update', type: 'room-permissions-update',
roomId: room.id, roomId: room.id,
permissions: updated.permissions permissions: nextRoom.permissions,
room: {
roles: nextRoom.roles,
roleAssignments: nextRoom.roleAssignments,
channelPermissions: nextRoom.channelPermissions,
slowModeInterval: nextRoom.slowModeInterval
}
});
this.serverDirectory.updateServer(room.id, {
currentOwnerId: currentUser.id,
roles: nextRoom.roles,
roleAssignments: nextRoom.roleAssignments,
channelPermissions: nextRoom.channelPermissions,
slowModeInterval: nextRoom.slowModeInterval
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
error: () => {}
}); });
return of(RoomsActions.updateRoom({ roomId: room.id, return of(RoomsActions.updateRoom({ roomId: room.id,
changes: updated })); changes: nextChanges }));
}) })
) )
); );
@@ -1047,12 +1133,8 @@ export class RoomsEffects {
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' })); return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' }));
} }
const role = currentUser.role;
const perms = currentRoom.permissions || {};
const isOwner = currentRoom.hostId === currentUser.id; const isOwner = currentRoom.hostId === currentUser.id;
const canByRole = const canByRole = resolveRoomPermission(currentRoom, currentUser, 'manageIcon');
(role === 'admin' && perms.adminsManageIcon) ||
(role === 'moderator' && perms.moderatorsManageIcon);
if (!isOwner && !canByRole) { if (!isOwner && !canByRole) {
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' })); return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
@@ -1472,6 +1554,7 @@ export class RoomsEffects {
serverDescription: room.description, serverDescription: room.description,
serverRoute: `/room/${room.id}` serverRoute: `/room/${room.id}`
}); });
this.voiceSessionService.setViewingVoiceServer(wasViewingVoiceServer); this.voiceSessionService.setViewingVoiceServer(wasViewingVoiceServer);
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'voice-state', type: 'voice-state',
@@ -1522,9 +1605,13 @@ export class RoomsEffects {
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined, maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
icon: typeof room.icon === 'string' ? room.icon : undefined, icon: typeof room.icon === 'string' ? room.icon : undefined,
iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined, iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined,
slowModeInterval: typeof room.slowModeInterval === 'number' ? room.slowModeInterval : undefined,
permissions: room.permissions ? { ...room.permissions } : undefined, permissions: room.permissions ? { ...room.permissions } : undefined,
channels: Array.isArray(room.channels) ? room.channels : undefined, channels: Array.isArray(room.channels) ? room.channels : undefined,
members: Array.isArray(room.members) ? room.members : undefined, members: Array.isArray(room.members) ? room.members : undefined,
roles: Array.isArray(room.roles) ? room.roles : undefined,
roleAssignments: Array.isArray(room.roleAssignments) ? room.roleAssignments : undefined,
channelPermissions: Array.isArray(room.channelPermissions) ? room.channelPermissions : undefined,
sourceId: typeof room.sourceId === 'string' ? room.sourceId : undefined, sourceId: typeof room.sourceId === 'string' ? room.sourceId : undefined,
sourceName: typeof room.sourceName === 'string' ? room.sourceName : undefined, sourceName: typeof room.sourceName === 'string' ? room.sourceName : undefined,
sourceUrl: typeof room.sourceUrl === 'string' ? room.sourceUrl : undefined sourceUrl: typeof room.sourceUrl === 'string' ? room.sourceUrl : undefined
@@ -1665,16 +1752,23 @@ export class RoomsEffects {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms); const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const permissions = event.permissions as Partial<RoomPermissions> | undefined; const permissions = event.permissions as Partial<RoomPermissions> | undefined;
const incomingRoom = event.room as Partial<Room> | undefined;
if (!room || !permissions) if (!room || (!permissions && !incomingRoom))
return EMPTY; return EMPTY;
return of( return of(
RoomsActions.updateRoom({ RoomsActions.updateRoom({
roomId: room.id, roomId: room.id,
changes: { changes: {
permissions: { ...(room.permissions || {}), permissions: permissions
? { ...(room.permissions || {}),
...permissions } as RoomPermissions ...permissions } as RoomPermissions
: room.permissions,
roles: Array.isArray(incomingRoom?.roles) ? incomingRoom.roles : room.roles,
roleAssignments: Array.isArray(incomingRoom?.roleAssignments) ? incomingRoom.roleAssignments : room.roleAssignments,
channelPermissions: Array.isArray(incomingRoom?.channelPermissions) ? incomingRoom.channelPermissions : room.channelPermissions,
slowModeInterval: typeof incomingRoom?.slowModeInterval === 'number' ? incomingRoom.slowModeInterval : room.slowModeInterval
} }
}) })
); );
@@ -1764,11 +1858,8 @@ export class RoomsEffects {
if (!sender) if (!sender)
return EMPTY; return EMPTY;
const perms = room.permissions || {};
const isOwner = room.hostId === sender.id; const isOwner = room.hostId === sender.id;
const canByRole = const canByRole = resolveRoomPermission(room, sender, 'manageIcon');
(sender.role === 'admin' && perms.adminsManageIcon) ||
(sender.role === 'moderator' && perms.moderatorsManageIcon);
if (!isOwner && !canByRole) if (!isOwner && !canByRole)
return EMPTY; return EMPTY;
@@ -2004,15 +2095,7 @@ export class RoomsEffects {
} }
private getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null { private getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
if (room.hostId === currentUser.id || room.hostId === currentUser.oderId) return resolveLegacyRole(currentRoom?.id === room.id ? currentRoom : room, currentUser);
return 'host';
if (currentRoom?.id === room.id && currentUser.role)
return currentUser.role;
return findRoomMember(room.members ?? [], currentUser.id)?.role
|| findRoomMember(room.members ?? [], currentUser.oderId)?.role
|| null;
} }
private canManageChannelsInRoom( private canManageChannelsInRoom(
@@ -2021,21 +2104,7 @@ export class RoomsEffects {
currentRoom: Room | null, currentRoom: Room | null,
currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom) currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom)
): boolean { ): boolean {
if (currentUserRole === 'host') { return currentUserRole === 'host' || resolveRoomPermission(room, currentUser, 'manageChannels');
return true;
}
const permissions = room.permissions || {};
if (currentUserRole === 'admin' && permissions.adminsManageRooms) {
return true;
}
if (currentUserRole === 'moderator' && permissions.moderatorsManageRooms) {
return true;
}
return false;
} }
private getPersistedCurrentUserId(): string | null { private getPersistedCurrentUserId(): string | null {

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