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

View File

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

View File

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

View File

@@ -3,8 +3,23 @@ import { RoomEntity } from '../../../entities';
import { replaceRoomRelations } from '../../relations';
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> {
const { room } = command.payload;
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(RoomEntity);
const entity = repo.create({
@@ -21,7 +36,7 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
maxUsers: room.maxUsers ?? null,
icon: room.icon ?? null,
iconUpdatedAt: room.iconUpdatedAt ?? null,
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
slowModeInterval: extractSlowModeInterval(room),
sourceId: room.sourceId ?? null,
sourceName: room.sourceName ?? null,
sourceUrl: room.sourceUrl ?? null
@@ -30,7 +45,11 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
await repo.save(entity);
await replaceRoomRelations(manager, room.id, {
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> {
const { messageId, updates } = command.payload;
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(MessageEntity);
const existing = await repo.findOne({ where: { id: messageId } });

View File

@@ -5,19 +5,32 @@ import { UpdateRoomCommand } from '../../types';
import {
applyUpdates,
boolToInt,
jsonOrNull,
TransformMap
} from './utils/applyUpdates';
const ROOM_TRANSFORMS: TransformMap = {
hasPassword: boolToInt,
isPrivate: boolToInt,
userCount: (val) => (val ?? 0),
permissions: jsonOrNull
userCount: (val) => (val ?? 0)
};
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> {
const { roomId, updates } = command.payload;
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(RoomEntity);
const existing = await repo.findOne({ where: { id: roomId } });
@@ -25,13 +38,30 @@ export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: D
if (!existing)
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);
await repo.save(existing);
await replaceRoomRelations(manager, roomId, {
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 { BanEntity } from '../entities/BanEntity';
import { AttachmentEntity } from '../entities/AttachmentEntity';
import { ReactionPayload } from './types';
import {
ReactionPayload,
RoomPayload
} from './types';
relationRecordToRoomPayload,
RoomChannelPermissionRecord,
RoomChannelRecord,
RoomMemberRecord,
RoomRoleAssignmentRecord,
RoomRoleRecord
} from './relations';
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
@@ -55,8 +60,28 @@ export function rowToUser(row: UserEntity) {
export function rowToRoom(
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 {
id: row.id,
name: row.name,
@@ -71,9 +96,13 @@ export function rowToRoom(
maxUsers: row.maxUsers ?? undefined,
icon: row.icon ?? undefined,
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
channels: relations.channels ?? [],
members: relations.members ?? [],
slowModeInterval: row.slowModeInterval,
permissions: relationPayload.permissions,
channels: relationPayload.channels,
members: relationPayload.members,
roles: relationPayload.roles,
roleAssignments: relationPayload.roleAssignments,
channelPermissions: relationPayload.channelPermissions,
sourceId: row.sourceId ?? undefined,
sourceName: row.sourceName ?? undefined,
sourceUrl: row.sourceUrl ?? undefined

View File

@@ -6,13 +6,54 @@ import {
import {
ReactionEntity,
RoomChannelEntity,
RoomMemberEntity
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity
} from '../entities';
import { ReactionPayload } from './types';
import {
AccessRolePayload,
ChannelPermissionPayload,
PermissionStatePayload,
ReactionPayload,
RoleAssignmentPayload,
RoomPayload,
RoomPermissionKeyPayload
} from './types';
type ChannelType = 'text' | 'voice';
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 {
id: string;
name: string;
@@ -27,13 +68,23 @@ export interface RoomMemberRecord {
displayName: string;
avatarUrl?: string;
role: RoomMemberRole;
roleIds?: string[];
joinedAt: number;
lastSeenAt: number;
}
export type RoomRoleRecord = AccessRolePayload;
export type RoomRoleAssignmentRecord = RoleAssignmentPayload;
export type RoomChannelPermissionRecord = ChannelPermissionPayload;
interface RoomRelationRecord {
channels: RoomChannelRecord[];
members: RoomMemberRecord[];
roles: RoomRoleRecord[];
roleAssignments: RoomRoleAssignmentRecord[];
channelPermissions: RoomChannelPermissionRecord[];
}
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() || '';
}
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 {
return member.displayName || member.username || member.oderId || member.id || 'User';
}
@@ -92,46 +193,161 @@ function mergeRoomMemberRole(
}
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) {
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 {
const normalizedId = typeof rawMember['id'] === 'string' ? rawMember['id'].trim() : '';
const normalizedOderId = typeof rawMember['oderId'] === 'string' ? rawMember['oderId'].trim() : '';
const normalizedId = trimmedString(rawMember, 'id');
const normalizedOderId = trimmedString(rawMember, 'oderId');
const normalizedKey = normalizedOderId || normalizedId;
if (!normalizedKey) {
return null;
}
const lastSeenAt = isFiniteNumber(rawMember['lastSeenAt'])
? rawMember['lastSeenAt']
: isFiniteNumber(rawMember['joinedAt'])
? rawMember['joinedAt']
: 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 { joinedAt, lastSeenAt } = resolveRoomMemberTimes(rawMember, now);
const username = trimmedString(rawMember, 'username');
const displayName = trimmedString(rawMember, 'displayName');
const avatarUrl = trimmedString(rawMember, 'avatarUrl');
const member: RoomMemberRecord = {
return {
id: normalizedId || normalizedKey,
oderId: normalizedOderId || undefined,
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
avatarUrl: avatarUrl || undefined,
role: normalizeRoomMemberRole(rawMember['role']),
roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined),
joinedAt,
lastSeenAt
};
return member;
}
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
@@ -154,11 +370,176 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
? (incomingMember.avatarUrl || existingMember.avatarUrl)
: (existingMember.avatarUrl || incomingMember.avatarUrl),
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
roleIds: preferIncoming
? (incomingMember.roleIds || existingMember.roleIds)
: (existingMember.roleIds || incomingMember.roleIds),
joinedAt: Math.min(existingMember.joinedAt, incomingMember.joinedAt),
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[] {
if (!Array.isArray(rawReactions)) {
return [];
@@ -233,30 +614,34 @@ export function normalizeRoomChannels(rawChannels: unknown): RoomChannelRecord[]
return channels;
}
export function normalizeRoomMembers(rawMembers: unknown, now: number = Date.now()): RoomMemberRecord[] {
if (!Array.isArray(rawMembers)) {
return [];
}
function deriveLegacyPermissions(roles: readonly RoomRoleRecord[], slowModeInterval: number): LegacyRoomPermissions {
const everyoneRole = roles.find((role) => role.id === SYSTEM_ROLE_IDS.everyone);
const moderatorRole = roles.find((role) => role.id === SYSTEM_ROLE_IDS.moderator);
const adminRole = roles.find((role) => role.id === SYSTEM_ROLE_IDS.admin);
const toBoolean = (role: RoomRoleRecord | undefined, permission: RoomPermissionKeyPayload, fallbackValue: boolean): boolean => {
const state = role?.permissions?.[permission];
const membersByKey = new Map<string, RoomMemberRecord>();
for (const rawMember of rawMembers) {
if (!rawMember || typeof rawMember !== 'object') {
continue;
if (state === 'allow') {
return true;
}
const member = normalizeRoomMember(rawMember as Record<string, unknown>, now);
if (!member) {
continue;
if (state === 'deny') {
return false;
}
const key = memberKey(member);
return fallbackValue;
};
membersByKey.set(key, mergeRoomMembers(membersByKey.get(key), member));
}
return Array.from(membersByKey.values()).sort(compareRoomMembers);
return {
allowVoice: toBoolean(everyoneRole, 'joinVoice', true),
allowScreenShare: toBoolean(everyoneRole, 'shareScreen', true),
allowFileUploads: toBoolean(everyoneRole, 'uploadFiles', true),
adminsManageRooms: adminRole?.permissions?.manageChannels === 'allow',
moderatorsManageRooms: moderatorRole?.permissions?.manageChannels === 'allow',
adminsManageIcon: adminRole?.permissions?.manageIcon === 'allow',
moderatorsManageIcon: moderatorRole?.permissions?.manageIcon === 'allow',
slowModeInterval
};
}
export async function replaceMessageReactions(
@@ -311,11 +696,12 @@ export async function loadMessageReactionsMap(
emoji: row.emoji,
timestamp: row.timestamp
});
groupedReactions.set(row.messageId, reactions);
}
for (const reactions of groupedReactions.values()) {
reactions.sort((first, second) => first.timestamp - second.timestamp);
reactions.sort((firstReaction, secondReaction) => firstReaction.timestamp - secondReaction.timestamp);
}
return groupedReactions;
@@ -324,8 +710,22 @@ export async function loadMessageReactionsMap(
export async function replaceRoomRelations(
manager: EntityManager,
roomId: string,
options: { channels?: unknown; members?: unknown }
options: {
channels?: unknown;
members?: unknown;
roles?: unknown;
roleAssignments?: unknown;
channelPermissions?: unknown;
permissions?: unknown;
}
): Promise<void> {
const normalizedMembers = options.members !== undefined
? normalizeRoomMembers(options.members)
: [];
const normalizedRoles = options.roles !== undefined
? normalizeRoomRoles(options.roles, options.permissions)
: [];
if (options.channels !== undefined) {
const channelRepo = manager.getRepository(RoomChannelEntity);
const channels = normalizeRoomChannels(options.channels);
@@ -347,13 +747,12 @@ export async function replaceRoomRelations(
if (options.members !== undefined) {
const memberRepo = manager.getRepository(RoomMemberEntity);
const members = normalizeRoomMembers(options.members);
await memberRepo.delete({ roomId });
if (members.length > 0) {
if (normalizedMembers.length > 0) {
await memberRepo.insert(
members.map((member) => ({
normalizedMembers.map((member) => ({
roomId,
memberKey: memberKey(member),
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(
@@ -380,31 +857,51 @@ export async function loadRoomRelationsMap(
return groupedRelations;
}
const [channelRows, memberRows] = await Promise.all([
const [
channelRows,
memberRows,
roleRows,
assignmentRows,
channelPermissionRows
] = await Promise.all([
dataSource.getRepository(RoomChannelEntity).find({
where: { roomId: In([...roomIds]) }
}),
dataSource.getRepository(RoomMemberEntity).find({
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) {
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] };
for (const roomId of roomIds) {
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,
name: row.name,
type: row.type,
position: row.position
});
groupedRelations.set(row.roomId, relation);
}
for (const row of memberRows) {
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] };
relation.members.push({
groupedRelations.get(row.roomId)?.members.push({
id: row.id,
oderId: row.oderId ?? undefined,
username: row.username,
@@ -414,13 +911,92 @@ export async function loadRoomRelationsMap(
joinedAt: row.joinedAt,
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()) {
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.roles.sort(compareRoles);
relation.roleAssignments.sort(compareAssignments);
}
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;
}
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 {
id: string;
oderId?: string;
@@ -92,9 +130,13 @@ export interface RoomPayload {
maxUsers?: number;
icon?: string;
iconUpdatedAt?: number;
slowModeInterval?: number;
permissions?: unknown;
channels?: unknown[];
members?: unknown[];
roles?: AccessRolePayload[];
roleAssignments?: RoleAssignmentPayload[];
channelPermissions?: ChannelPermissionPayload[];
sourceId?: string;
sourceName?: string;
sourceUrl?: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,9 @@ export { UserEntity } from './UserEntity';
export { RoomEntity } from './RoomEntity';
export { RoomChannelEntity } from './RoomChannelEntity';
export { RoomMemberEntity } from './RoomMemberEntity';
export { RoomRoleEntity } from './RoomRoleEntity';
export { RoomUserRoleEntity } from './RoomUserRoleEntity';
export { RoomChannelPermissionEntity } from './RoomChannelPermissionEntity';
export { ReactionEntity } from './ReactionEntity';
export { BanEntity } from './BanEntity';
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) => {
return await writeSavedTheme(fileName, text);
});
ipcMain.handle('delete-saved-theme', async (_event, fileName: string) => {
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 entries = await fsp.readdir(themesPath, { withFileTypes: true });
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'));
const descriptors = await Promise.all(files.map(async (entry) => {
const filePath = path.join(themesPath, entry.name);
const stats = await fsp.stat(filePath);
@@ -89,4 +88,4 @@ export async function deleteSavedTheme(fileName: string): Promise<boolean> {
throw error;
}
}
}

View File

@@ -1,8 +1,11 @@
import { DataSource } from 'typeorm';
import {
ServerChannelPermissionEntity,
ServerChannelEntity,
ServerEntity,
ServerRoleEntity,
ServerTagEntity,
ServerUserRoleEntity,
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
@@ -16,6 +19,9 @@ export async function handleDeleteServer(command: DeleteServerCommand, dataSourc
await dataSource.transaction(async (manager) => {
await manager.getRepository(ServerTagEntity).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(ServerMembershipEntity).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> {
const { server } = command.payload;
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(ServerEntity);
const entity = repo.create({
@@ -17,6 +18,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
isPrivate: server.isPrivate ? 1 : 0,
maxUsers: server.maxUsers,
currentUsers: server.currentUsers,
slowModeInterval: server.slowModeInterval ?? 0,
createdAt: server.createdAt,
lastSeen: server.lastSeen
});
@@ -24,7 +26,10 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
await repo.save(entity);
await replaceServerRelations(manager, server.id, {
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,
JoinRequestPayload
} from './types';
import { relationRecordToServerPayload } from './relations';
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
return {
@@ -19,8 +20,22 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
export function rowToServer(
row: ServerEntity,
relations: Pick<ServerPayload, 'tags' | 'channels'> = { tags: [], channels: [] }
relations: Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions'> = {
tags: [],
channels: [],
roles: [],
roleAssignments: [],
channelPermissions: []
}
): ServerPayload {
const relationPayload = relationRecordToServerPayload({ slowModeInterval: row.slowModeInterval }, {
tags: relations.tags ?? [],
channels: relations.channels ?? [],
roles: relations.roles ?? [],
roleAssignments: relations.roleAssignments ?? [],
channelPermissions: relations.channelPermissions ?? []
});
return {
id: row.id,
name: row.name,
@@ -32,8 +47,12 @@ export function rowToServer(
isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers,
currentUsers: row.currentUsers,
tags: relations.tags ?? [],
channels: relations.channels ?? [],
slowModeInterval: relationPayload.slowModeInterval,
tags: relationPayload.tags,
channels: relationPayload.channels,
roles: relationPayload.roles,
roleAssignments: relationPayload.roleAssignments,
channelPermissions: relationPayload.channelPermissions,
createdAt: row.createdAt,
lastSeen: row.lastSeen
};

View File

@@ -5,13 +5,46 @@ import {
} from 'typeorm';
import {
ServerChannelEntity,
ServerTagEntity
ServerTagEntity,
ServerRoleEntity,
ServerUserRoleEntity,
ServerChannelPermissionEntity
} 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 {
tags: string[];
channels: ServerChannelPayload[];
roles: AccessRolePayload[];
roleAssignments: RoleAssignmentPayload[];
channelPermissions: ChannelPermissionPayload[];
}
function normalizeChannelName(name: string): string {
@@ -22,16 +55,125 @@ function channelNameKey(type: ServerChannelPayload['type'], name: string): strin
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 {
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[] {
if (!Array.isArray(rawTags)) {
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[] {
@@ -72,19 +214,169 @@ export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayl
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(
manager: EntityManager,
serverId: string,
options: { tags: unknown; channels: unknown }
options: {
tags: unknown;
channels: unknown;
roles?: unknown;
roleAssignments?: unknown;
channelPermissions?: unknown;
}
): Promise<void> {
const tagRepo = manager.getRepository(ServerTagEntity);
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 channels = normalizeServerChannels(options.channels);
const roles = options.roles !== undefined ? normalizeServerRoles(options.roles) : [];
await tagRepo.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) {
await tagRepo.insert(
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(
@@ -118,43 +470,134 @@ export async function loadServerRelationsMap(
return groupedRelations;
}
const [tagRows, channelRows] = await Promise.all([
const [
tagRows,
channelRows,
roleRows,
userRoleRows,
channelPermissionRows
] = await Promise.all([
dataSource.getRepository(ServerTagEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerChannelEntity).find({
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) {
const relation = groupedRelations.get(row.serverId) ?? { tags: [], channels: [] };
for (const serverId of serverIds) {
groupedRelations.set(serverId, {
tags: [],
channels: [],
roles: [],
roleAssignments: [],
channelPermissions: []
});
}
relation.tags.push(row.value);
groupedRelations.set(row.serverId, relation);
for (const row of tagRows) {
groupedRelations.get(row.serverId)?.tags.push(row.value);
}
for (const row of channelRows) {
const relation = groupedRelations.get(row.serverId) ?? { tags: [], channels: [] };
relation.channels.push({
groupedRelations.get(row.serverId)?.channels.push({
id: row.channelId,
name: row.name,
type: row.type,
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) {
const orderedTags = tagRows
relation.tags = tagRows
.filter((row) => row.serverId === serverId)
.sort((first, second) => first.position - second.position)
.sort((firstTag, secondTag) => firstTag.position - secondTag.position)
.map((row) => row.value);
relation.tags = orderedTags;
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.roles.sort(compareRoles);
relation.roleAssignments.sort(compareAssignments);
}
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;
}
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 {
id: string;
name: string;
@@ -48,8 +86,12 @@ export interface ServerPayload {
isPrivate: boolean;
maxUsers: number;
currentUsers: number;
slowModeInterval?: number;
tags: string[];
channels: ServerChannelPayload[];
roles?: AccessRolePayload[];
roleAssignments?: RoleAssignmentPayload[];
channelPermissions?: ChannelPermissionPayload[];
createdAt: number;
lastSeen: number;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
updateJoinRequestStatus
} from '../cqrs';
import { notifyUser } from '../websocket/broadcast';
import { resolveServerPermission } from '../services/server-permissions.service';
const router = Router();
@@ -19,7 +20,7 @@ router.put('/:id', async (req, res) => {
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' });
await updateJoinRequestStatus(id, status as JoinRequestPayload['status']);

View File

@@ -1,9 +1,6 @@
import { Response, Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import {
ServerChannelPayload,
ServerPayload
} from '../cqrs/types';
import { ServerChannelPayload, ServerPayload } from '../cqrs/types';
import {
getAllPublicServers,
getServerById,
@@ -30,21 +27,18 @@ import {
buildInviteUrl,
getRequestOrigin
} from './invite-utils';
import {
canManageServerUpdate,
canModerateServerMember,
resolveServerPermission
} from '../services/server-permissions.service';
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 {
return `${type}:${name.toLocaleLowerCase()}`;
}
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
return !!role && allowedRoles.includes(role);
}
function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
if (!Array.isArray(value)) {
return [];
@@ -212,15 +206,20 @@ router.put('/:id', async (req, res) => {
} = req.body;
const existing = await getServerById(id);
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
const normalizedRole = normalizeRole(actingRole);
if (!existing)
return res.status(404).json({ error: 'Server not found' });
if (
existing.ownerId !== authenticatedOwnerId &&
!isAllowedRole(normalizedRole, ['host', 'admin'])
) {
if (!authenticatedOwnerId) {
return res.status(400).json({ error: 'Missing currentOwnerId' });
}
if (!canManageServerUpdate(existing, authenticatedOwnerId, {
...updates,
channels,
password,
actingRole
})) {
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) => {
const { id: serverId } = req.params;
const { actorUserId, actorRole, targetUserId } = req.body;
const { actorUserId, targetUserId } = req.body;
const server = await getServerById(serverId);
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' });
}
if (
server.ownerId !== actorUserId &&
!isAllowedRole(normalizeRole(actorRole), [
'host',
'admin',
'moderator'
])
) {
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) {
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) => {
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);
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' });
}
if (
server.ownerId !== actorUserId &&
!isAllowedRole(normalizeRole(actorRole), [
'host',
'admin',
'moderator'
])
) {
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) {
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) => {
const { id: serverId } = req.params;
const { actorUserId, actorRole, banId, targetUserId } = req.body;
const { actorUserId, banId, targetUserId } = req.body;
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
if (
server.ownerId !== actorUserId &&
!isAllowedRole(normalizeRole(actorRole), [
'host',
'admin',
'moderator'
])
) {
if (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) {
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": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2.15MB"
"maximumWarning": "2.2MB",
"maximumError": "2.3MB"
},
{
"type": "anyComponentStyle",

View File

@@ -26,7 +26,7 @@
@if (isThemeStudioFullscreen()) {
<div class="theme-studio-fullscreen-shell absolute inset-0 overflow-y-auto overflow-x-hidden bg-background">
@if (themeStudioFullscreenComponent()) {
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()"></ng-container>
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()" />
} @else {
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio…</div>
}

View File

@@ -40,10 +40,7 @@ import { ScreenShareSourcePickerComponent } from './shared/components/screen-sha
import { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
import {
ROOM_URL_PATTERN,
STORAGE_KEY_CURRENT_USER_ID
} from './core/constants';
import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID } from './core/constants';
import {
ThemeNodeDirective,
ThemePickerOverlayComponent,
@@ -241,7 +238,6 @@ export class App implements OnInit, OnDestroy {
this.router.events.subscribe((evt) => {
if (evt instanceof NavigationEnd) {
const url = evt.urlAfterRedirects || evt.url;
const roomMatch = url.match(ROOM_URL_PATTERN);
const currentRoomId = roomMatch ? roomMatch[1] : null;
@@ -274,14 +270,17 @@ export class App implements OnInit, OnDestroy {
width: rect.width,
height: rect.height
};
this.themeStudioControlsDragOffset = {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
this.themeStudioControlsPosition.set({
x: rect.left,
y: rect.top
});
this.isDraggingThemeStudioControls.set(true);
event.preventDefault();
}

View File

@@ -6,23 +6,25 @@ infrastructure adapters and UI.
## Quick reference
| Domain | Purpose | Public entry point |
|---|---|---|
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
| Domain | Purpose | Public entry point |
| -------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
| **access-control** | Role, permission, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()` |
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
## Detailed docs
The larger domains also keep longer design notes in their own folders:
- [attachment/README.md](attachment/README.md)
- [access-control/README.md](access-control/README.md)
- [auth/README.md](auth/README.md)
- [chat/README.md](chat/README.md)
- [notifications/README.md](notifications/README.md)
@@ -66,12 +68,12 @@ domains/<name>/
## Where do I put new code?
| I want to… | Put it in… |
|---|---|
| Add a new business concept | New folder under `domains/` following the convention above |
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
| Add a settings subpanel | `domains/<name>/feature/settings/` |
| Add a top-level page or shell component | `features/` |
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
| Add realtime/WebRTC logic | `infrastructure/realtime/` |
| I want to… | Put it in… |
| --------------------------------------- | ----------------------------------------------------------------- |
| Add a new business concept | New folder under `domains/` following the convention above |
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
| Add a settings subpanel | `domains/<name>/feature/settings/` |
| Add a top-level page or shell component | `features/` |
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
| Add realtime/WebRTC logic | `infrastructure/realtime/` |

View File

@@ -0,0 +1,37 @@
# Access Control Domain
Role and permission rules for servers, including default system roles, role assignment normalization, permission resolution, legacy compatibility mapping, and room-level access-control hydration.
## Module map
```
access-control/
├── domain/
│ ├── access-control.models.ts MemberIdentity and RoomPermissionDefinition domain types
│ ├── access-control.constants.ts SYSTEM_ROLE_IDS and permission metadata
│ ├── role.rules.ts Role defaults, normalization, ordering, create/update helpers
│ ├── role-assignment.rules.ts Assignment normalization and member-role lookups
│ ├── permission.rules.ts Permission resolution and moderation hierarchy checks
│ ├── room.rules.ts Legacy compatibility, room hydration, room-level normalization
│ └── access-control.logic.ts Public barrel for domain rules
└── index.ts Domain barrel used by other layers
```
## Domain rules
| Function | Purpose |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `normalizeRoomRoles(room.roles, room.permissions)` | Repairs missing/default roles and keeps role ordering stable |
| `normalizeRoomRoleAssignments(...)` | Deduplicates and backfills member role assignments from legacy member role fields |
| `normalizeChannelPermissionOverrides(...)` | Deduplicates valid channel overrides and drops invalid references |
| `resolveRoomPermission(room, identity, permission, channelId?)` | Resolves effective permission state including overrides |
| `canManageMember(...)` | Applies both permission checks and role hierarchy checks |
| `canManageRole(...)` | Prevents editing roles at or above the actor's highest role |
| `normalizeRoomAccessControl(room)` | Produces a fully hydrated room with normalized roles, assignments, overrides, and legacy compatibility fields |
## Layering
- Domain rules stay pure and only depend on `shared-kernel` contracts plus other files in this domain.
- Renderer shells and NgRx effects should keep importing from `src/app/domains/access-control/` instead of internal files.
- Legacy `room.permissions` booleans remain compatibility output only; normalized data lives on `roles`, `roleAssignments`, `channelPermissions`, and `slowModeInterval`.

View File

@@ -0,0 +1,65 @@
import type { RoomPermissionDefinition } from './access-control.models';
export const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
export const ROOM_PERMISSION_DEFINITIONS: RoomPermissionDefinition[] = [
{
key: 'manageServer',
label: 'Manage Server',
description: 'Edit server settings such as name, privacy, and limits.'
},
{
key: 'manageRoles',
label: 'Manage Roles',
description: 'Create, edit, reorder, and assign roles.'
},
{
key: 'manageChannels',
label: 'Manage Channels',
description: 'Create, rename, delete, and reorder channels.'
},
{
key: 'manageIcon',
label: 'Manage Icon',
description: 'Change the server icon for all members.'
},
{
key: 'kickMembers',
label: 'Kick Members',
description: 'Remove members from the server without banning them.'
},
{
key: 'banMembers',
label: 'Ban Members',
description: 'Ban members from the server.'
},
{
key: 'manageBans',
label: 'Manage Bans',
description: 'Review and revoke existing bans.'
},
{
key: 'deleteMessages',
label: 'Delete Messages',
description: 'Delete messages sent by other members.'
},
{
key: 'joinVoice',
label: 'Join Voice',
description: 'Join voice channels.'
},
{
key: 'shareScreen',
label: 'Share Screen',
description: 'Start screen sharing in voice channels.'
},
{
key: 'uploadFiles',
label: 'Upload Files',
description: 'Upload attachments in chat.'
}
];

View File

@@ -0,0 +1,117 @@
import {
PermissionState,
RoomPermissionKey,
RoomPermissionMatrix,
RoomRole,
RoomRoleAssignment,
ROOM_PERMISSION_KEYS
} from '../../../shared-kernel';
import type { MemberIdentity } from './access-control.models';
export function normalizeName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
export function compareText(firstValue: string, secondValue: string): number {
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
}
export function uniqueStrings(values: readonly string[]): string[] {
return Array.from(new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0).map((value) => value.trim())));
}
export function normalizePermissionState(value: unknown): PermissionState {
return value === 'allow' || value === 'deny' || value === 'inherit' ? value : 'inherit';
}
export function normalizePermissionMatrix(matrix: RoomPermissionMatrix | undefined): RoomPermissionMatrix {
const normalized: RoomPermissionMatrix = {};
for (const key of ROOM_PERMISSION_KEYS) {
const value = normalizePermissionState(matrix?.[key]);
if (value !== 'inherit') {
normalized[key] = value;
}
}
return normalized;
}
export function memberIdentityKey(identity: MemberIdentity | null | undefined): string {
return identity?.oderId?.trim() || identity?.id?.trim() || '';
}
export function matchesIdentity(identity: MemberIdentity | null | undefined, candidate: Pick<RoomRoleAssignment, 'userId' | 'oderId'>): boolean {
if (!identity) {
return false;
}
return !!(
(identity.id && (candidate.userId === identity.id || candidate.oderId === identity.id)) ||
(identity.oderId && (candidate.userId === identity.oderId || candidate.oderId === identity.oderId))
);
}
export function roleSortAscending(firstRole: RoomRole, secondRole: RoomRole): number {
if (firstRole.position !== secondRole.position) {
return firstRole.position - secondRole.position;
}
return compareText(firstRole.name, secondRole.name);
}
export function roleSortDescending(firstRole: RoomRole, secondRole: RoomRole): number {
return roleSortAscending(secondRole, firstRole);
}
export function permissionStateToBoolean(value: PermissionState | undefined, fallbackValue: boolean): boolean {
if (value === 'allow') {
return true;
}
if (value === 'deny') {
return false;
}
return fallbackValue;
}
export function getRolePermissionState(role: RoomRole | undefined, permission: RoomPermissionKey): PermissionState {
return normalizePermissionState(role?.permissions?.[permission]);
}
export function buildSystemRole(id: string, name: string, position: number, permissions: RoomPermissionMatrix, color: string): RoomRole {
return {
id,
name,
position,
color,
isSystem: true,
permissions: normalizePermissionMatrix(permissions)
};
}
export function buildRoleLookup(roles: readonly RoomRole[]): Map<string, RoomRole> {
return new Map(roles.map((role) => [role.id, role]));
}
export function nextRolePosition(roles: readonly RoomRole[]): number {
if (roles.length === 0) {
return 100;
}
return Math.max(...roles.map((role) => role.position)) + 100;
}
export function resolveLegacyAllowState(
value: boolean | undefined,
currentState: PermissionState | undefined,
disabledState: Exclude<PermissionState, 'allow'>
): PermissionState | undefined {
if (value === undefined) {
return currentState;
}
return value ? 'allow' : disabledState;
}

View File

@@ -0,0 +1,6 @@
export * from './access-control.models';
export * from './access-control.constants';
export * from './role.rules';
export * from './role-assignment.rules';
export * from './permission.rules';
export * from './room.rules';

View File

@@ -0,0 +1,13 @@
import type {
RoomMember,
RoomPermissionKey,
User
} from '../../../shared-kernel';
export interface RoomPermissionDefinition {
key: RoomPermissionKey;
label: string;
description: string;
}
export type MemberIdentity = Pick<RoomMember, 'id' | 'oderId'> | Pick<User, 'id' | 'oderId'> | { id?: string; oderId?: string };

View File

@@ -0,0 +1,248 @@
import {
ChannelPermissionOverride,
PermissionState,
Room,
RoomPermissionKey,
RoomRole
} from '../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from './access-control.constants';
import type { MemberIdentity } from './access-control.models';
import {
buildRoleLookup,
getRolePermissionState,
matchesIdentity,
normalizePermissionState,
roleSortAscending,
compareText
} from './access-control.internal';
import { getAssignedRoleIds, getHighestAssignedRole } from './role-assignment.rules';
import { getRoomRoleById, normalizeRoomRoles } from './role.rules';
function resolveRolePermissionState(roles: readonly RoomRole[], assignedRoleIds: readonly string[], permission: RoomPermissionKey): PermissionState {
const roleLookup = buildRoleLookup(roles);
const assignedRoles = assignedRoleIds.map((roleId) => roleLookup.get(roleId)).filter((role): role is RoomRole => !!role);
const effectiveRoles = [roleLookup.get(SYSTEM_ROLE_IDS.everyone), ...assignedRoles]
.filter((role): role is RoomRole => !!role)
.sort(roleSortAscending);
let state: PermissionState = 'inherit';
for (const role of effectiveRoles) {
const nextState = getRolePermissionState(role, permission);
if (nextState !== 'inherit') {
state = nextState;
}
}
return state;
}
function resolveChannelOverrideState(
overrides: readonly ChannelPermissionOverride[],
roles: readonly RoomRole[],
assignedRoleIds: readonly string[],
identity: MemberIdentity,
channelId: string,
permission: RoomPermissionKey,
baseState: PermissionState
): PermissionState {
const roleLookup = buildRoleLookup(roles);
let state = baseState;
const everyoneOverride = overrides.find(
(override) =>
override.channelId === channelId &&
override.targetType === 'role' &&
override.targetId === SYSTEM_ROLE_IDS.everyone &&
override.permission === permission
);
if (everyoneOverride?.value && everyoneOverride.value !== 'inherit') {
state = everyoneOverride.value;
}
const orderedAssignedRoles = assignedRoleIds
.map((roleId) => roleLookup.get(roleId))
.filter((role): role is RoomRole => !!role)
.sort(roleSortAscending);
for (const role of orderedAssignedRoles) {
const override = overrides.find(
(candidateOverride) =>
candidateOverride.channelId === channelId &&
candidateOverride.targetType === 'role' &&
candidateOverride.targetId === role.id &&
candidateOverride.permission === permission
);
if (override?.value && override.value !== 'inherit') {
state = override.value;
}
}
const userOverride = overrides.find(
(override) =>
override.channelId === channelId &&
override.targetType === 'user' &&
override.permission === permission &&
(override.targetId === identity.id || override.targetId === identity.oderId)
);
if (userOverride?.value && userOverride.value !== 'inherit') {
state = userOverride.value;
}
return state;
}
export function normalizeChannelPermissionOverrides(
overrides: readonly ChannelPermissionOverride[] | undefined,
roles: readonly RoomRole[]
): ChannelPermissionOverride[] {
const validRoleIds = new Set(roles.map((role) => role.id));
const normalizedByKey = new Map<string, ChannelPermissionOverride>();
for (const override of overrides ?? []) {
if (!override || typeof override !== 'object') {
continue;
}
const channelId = typeof override.channelId === 'string' ? override.channelId.trim() : '';
const targetId = typeof override.targetId === 'string' ? override.targetId.trim() : '';
const targetType = override.targetType === 'role' || override.targetType === 'user' ? override.targetType : null;
const permission = override.permission;
const value = normalizePermissionState(override.value);
if (!channelId || !targetId || !targetType || !permission || value === 'inherit') {
continue;
}
if (targetType === 'role' && !validRoleIds.has(targetId)) {
continue;
}
normalizedByKey.set(`${channelId}:${targetType}:${targetId}:${permission}`, {
channelId,
targetType,
targetId,
permission,
value
});
}
return Array.from(normalizedByKey.values()).sort((firstOverride, secondOverride) => {
const channelCompare = compareText(firstOverride.channelId, secondOverride.channelId);
if (channelCompare !== 0) {
return channelCompare;
}
if (firstOverride.targetType !== secondOverride.targetType) {
return compareText(firstOverride.targetType, secondOverride.targetType);
}
const targetCompare = compareText(firstOverride.targetId, secondOverride.targetId);
if (targetCompare !== 0) {
return targetCompare;
}
return compareText(firstOverride.permission, secondOverride.permission);
});
}
export function resolveRoomPermission(
room: Room,
identity: MemberIdentity | null | undefined,
permission: RoomPermissionKey,
channelId?: string
): boolean {
if (!identity) {
return false;
}
if (room.hostId === identity.id || room.hostId === identity.oderId) {
return true;
}
const roles = normalizeRoomRoles(room.roles, room.permissions);
const assignedRoleIds = getAssignedRoleIds(room.roleAssignments, identity);
const roleState = resolveRolePermissionState(roles, assignedRoleIds, permission);
const channelState = channelId
? resolveChannelOverrideState(
normalizeChannelPermissionOverrides(room.channelPermissions, roles),
roles,
assignedRoleIds,
identity,
channelId,
permission,
roleState
)
: roleState;
return channelState === 'allow';
}
export function canManageMember(
room: Room,
actor: MemberIdentity | null | undefined,
target: MemberIdentity | null | undefined,
permission: 'kickMembers' | 'banMembers' | 'manageRoles'
): boolean {
if (!actor || !target) {
return false;
}
const isActorOwner = room.hostId === actor.id || room.hostId === actor.oderId;
const isTargetOwner = room.hostId === target.id || room.hostId === target.oderId;
const isSameIdentity = matchesIdentity(actor, {
userId: target.id || target.oderId || '',
oderId: target.oderId
});
if (isSameIdentity) {
return false;
}
if (isTargetOwner && !isActorOwner) {
return false;
}
if (isActorOwner) {
return true;
}
if (!resolveRoomPermission(room, actor, permission)) {
return false;
}
const actorRole = getHighestAssignedRole(room, actor);
const targetRole = getHighestAssignedRole(room, target);
return (actorRole?.position ?? 0) > (targetRole?.position ?? 0);
}
export function canManageRole(room: Room, actor: MemberIdentity | null | undefined, roleId: string): boolean {
if (!actor || !roleId) {
return false;
}
if (room.hostId === actor.id || room.hostId === actor.oderId) {
return true;
}
if (!resolveRoomPermission(room, actor, 'manageRoles')) {
return false;
}
const targetRole = getRoomRoleById(normalizeRoomRoles(room.roles, room.permissions), roleId);
const actorRole = getHighestAssignedRole(room, actor);
if (!targetRole) {
return false;
}
return (actorRole?.position ?? 0) > targetRole.position;
}

View File

@@ -0,0 +1,201 @@
import {
Room,
RoomMember,
RoomRole,
RoomRoleAssignment
} from '../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from './access-control.constants';
import type { MemberIdentity } from './access-control.models';
import {
buildRoleLookup,
compareText,
memberIdentityKey,
matchesIdentity,
roleSortDescending,
uniqueStrings
} from './access-control.internal';
import { getRoomRoleById, normalizeRoomRoles } from './role.rules';
function sortAssignments(assignments: readonly RoomRoleAssignment[]): RoomRoleAssignment[] {
return [...assignments].sort((firstAssignment, secondAssignment) =>
compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId)
);
}
function resolveLegacyMemberRoleIds(member: RoomMember, validRoleIds: Set<string>): string[] {
if (Array.isArray(member.roleIds) && member.roleIds.length > 0) {
return uniqueStrings(member.roleIds).filter((roleId) => validRoleIds.has(roleId));
}
if (member.role === 'admin') {
return [SYSTEM_ROLE_IDS.admin];
}
if (member.role === 'moderator') {
return [SYSTEM_ROLE_IDS.moderator];
}
return [];
}
export function normalizeRoomRoleAssignments(
assignments: readonly RoomRoleAssignment[] | undefined,
members: readonly RoomMember[] | undefined,
roles: readonly RoomRole[]
): RoomRoleAssignment[] {
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
const normalizedByUserKey = new Map<string, RoomRoleAssignment>();
for (const assignment of assignments ?? []) {
if (!assignment || typeof assignment !== 'object') {
continue;
}
const userId = typeof assignment.userId === 'string' ? assignment.userId.trim() : '';
const oderId = typeof assignment.oderId === 'string' ? assignment.oderId.trim() : undefined;
const key = oderId || userId;
if (!key) {
continue;
}
const roleIds = uniqueStrings(assignment.roleIds ?? []).filter((roleId) => validRoleIds.has(roleId));
if (roleIds.length === 0) {
continue;
}
normalizedByUserKey.set(key, {
userId: userId || key,
oderId,
roleIds
});
}
if (normalizedByUserKey.size > 0) {
return sortAssignments(Array.from(normalizedByUserKey.values()));
}
for (const member of members ?? []) {
const key = memberIdentityKey(member);
if (!key) {
continue;
}
const roleIds = resolveLegacyMemberRoleIds(member, validRoleIds);
if (roleIds.length === 0) {
continue;
}
normalizedByUserKey.set(key, {
userId: member.id || key,
oderId: member.oderId || undefined,
roleIds
});
}
return sortAssignments(Array.from(normalizedByUserKey.values()));
}
export function getAssignedRoleIds(assignments: readonly RoomRoleAssignment[] | undefined, identity: MemberIdentity | null | undefined): string[] {
const assignment = (assignments ?? []).find((candidateAssignment) => matchesIdentity(identity, candidateAssignment));
return uniqueStrings(assignment?.roleIds ?? []);
}
export function getDisplayRoleName(room: Room, member: MemberIdentity | null | undefined): string {
if (!member) {
return 'Member';
}
if (room.hostId === member.id || room.hostId === member.oderId) {
return 'Owner';
}
const roles = normalizeRoomRoles(room.roles, room.permissions);
const roleLookup = buildRoleLookup(roles);
const assignedRoles = getAssignedRoleIds(room.roleAssignments, member)
.map((roleId) => roleLookup.get(roleId))
.filter((role): role is RoomRole => !!role)
.sort(roleSortDescending);
return assignedRoles[0]?.name || '@everyone';
}
export function getAssignedRoles(room: Room, identity: MemberIdentity | null | undefined): RoomRole[] {
const roles = normalizeRoomRoles(room.roles, room.permissions);
const roleLookup = buildRoleLookup(roles);
return getAssignedRoleIds(room.roleAssignments, identity)
.map((roleId) => roleLookup.get(roleId))
.filter((role): role is RoomRole => !!role)
.sort(roleSortDescending);
}
export function getHighestAssignedRole(room: Room, identity: MemberIdentity | null | undefined): RoomRole | null {
if (!identity) {
return null;
}
if (room.hostId === identity.id || room.hostId === identity.oderId) {
return null;
}
return getAssignedRoles(room, identity)[0] ?? getRoomRoleById(normalizeRoomRoles(room.roles, room.permissions), SYSTEM_ROLE_IDS.everyone) ?? null;
}
export function setRoleAssignmentsForMember(
assignments: readonly RoomRoleAssignment[] | undefined,
member: MemberIdentity,
roleIds: readonly string[]
): RoomRoleAssignment[] {
const nextAssignments = new Map<string, RoomRoleAssignment>();
const memberKey = memberIdentityKey(member);
for (const assignment of assignments ?? []) {
const key = memberIdentityKey({
id: assignment.userId,
oderId: assignment.oderId
});
if (!key || key === memberKey) {
continue;
}
nextAssignments.set(key, {
userId: assignment.userId,
oderId: assignment.oderId,
roleIds: uniqueStrings(assignment.roleIds)
});
}
const normalizedRoleIds = uniqueStrings(roleIds);
if (memberKey && normalizedRoleIds.length > 0) {
nextAssignments.set(memberKey, {
userId: member.id || member.oderId || memberKey,
oderId: member.oderId || undefined,
roleIds: normalizedRoleIds
});
}
return sortAssignments(Array.from(nextAssignments.values()));
}
export function removeRoleFromAssignments(assignments: readonly RoomRoleAssignment[] | undefined, roleId: string): RoomRoleAssignment[] {
return (assignments ?? [])
.map((assignment) => ({
...assignment,
roleIds: assignment.roleIds.filter((candidateRoleId) => candidateRoleId !== roleId)
}))
.filter((assignment) => assignment.roleIds.length > 0);
}
export function getRoleIdsForMember(room: Room, member: MemberIdentity | null | undefined): string[] {
return getAssignedRoleIds(
normalizeRoomRoleAssignments(room.roleAssignments, room.members, normalizeRoomRoles(room.roles, room.permissions)),
member
);
}

View File

@@ -0,0 +1,171 @@
import {
RoomPermissionMatrix,
RoomPermissions,
RoomRole
} from '../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from './access-control.constants';
import {
buildRoleLookup,
buildSystemRole,
nextRolePosition,
normalizeName,
normalizePermissionMatrix,
roleSortAscending,
roleSortDescending
} from './access-control.internal';
const ROLE_COLORS = {
everyone: '#6b7280',
moderator: '#10b981',
admin: '#60a5fa'
} as const;
function resolveNormalizedRolePosition(
position: unknown,
fallbackPosition: number | undefined,
existingRoles: readonly RoomRole[],
defaultRoles: readonly RoomRole[]
): number {
if (typeof position === 'number' && Number.isFinite(position)) {
return position;
}
if (typeof fallbackPosition === 'number') {
return fallbackPosition;
}
return nextRolePosition(existingRoles.length > 0 ? existingRoles : defaultRoles);
}
function normalizeRoomRoleEntry(
role: RoomRole | null | undefined,
defaultsById: Map<string, RoomRole>,
existingRoles: readonly RoomRole[],
defaultRoles: readonly RoomRole[]
): RoomRole | null {
if (!role) {
return null;
}
const id = typeof role.id === 'string' ? role.id.trim() : '';
const fallbackRole = defaultsById.get(id);
const name = normalizeName(typeof role.name === 'string' ? role.name : (fallbackRole?.name ?? 'Role'));
if (!id || !name) {
return null;
}
return {
id,
name,
position: resolveNormalizedRolePosition(role.position, fallbackRole?.position, existingRoles, defaultRoles),
color: typeof role.color === 'string' && role.color.trim() ? role.color.trim() : fallbackRole?.color,
isSystem: typeof role.isSystem === 'boolean' ? role.isSystem : fallbackRole?.isSystem,
permissions: normalizePermissionMatrix(role.permissions ?? fallbackRole?.permissions)
};
}
export function buildDefaultRoomRoles(legacyPermissions?: RoomPermissions): RoomRole[] {
const everyonePermissions: RoomPermissionMatrix = {
joinVoice: legacyPermissions?.allowVoice === false ? 'deny' : 'allow',
shareScreen: legacyPermissions?.allowScreenShare === false ? 'deny' : 'allow',
uploadFiles: legacyPermissions?.allowFileUploads === false ? 'deny' : 'allow'
};
const moderatorPermissions: RoomPermissionMatrix = {
kickMembers: 'allow',
deleteMessages: 'allow',
manageChannels: legacyPermissions?.moderatorsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions?.moderatorsManageIcon ? 'allow' : 'inherit'
};
const adminPermissions: RoomPermissionMatrix = {
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
manageChannels: legacyPermissions?.adminsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions?.adminsManageIcon ? 'allow' : 'inherit'
};
return [
buildSystemRole(SYSTEM_ROLE_IDS.everyone, '@everyone', 0, everyonePermissions, ROLE_COLORS.everyone),
buildSystemRole(SYSTEM_ROLE_IDS.moderator, 'Moderator', 200, moderatorPermissions, ROLE_COLORS.moderator),
buildSystemRole(SYSTEM_ROLE_IDS.admin, 'Admin', 300, adminPermissions, ROLE_COLORS.admin)
];
}
export function sortRolesForDisplay(roles: readonly RoomRole[]): RoomRole[] {
return [...roles].sort(roleSortDescending);
}
export function normalizeRoomRoles(roles: readonly RoomRole[] | undefined, legacyPermissions?: RoomPermissions): RoomRole[] {
const defaultRoles = buildDefaultRoomRoles(legacyPermissions);
const defaultsById = buildRoleLookup(defaultRoles);
const normalizedById = new Map<string, RoomRole>();
for (const role of roles ?? []) {
const normalizedRole = normalizeRoomRoleEntry(role, defaultsById, Array.from(normalizedById.values()), defaultRoles);
if (normalizedRole) {
normalizedById.set(normalizedRole.id, normalizedRole);
}
}
for (const [roleId, role] of defaultsById) {
if (!normalizedById.has(roleId)) {
normalizedById.set(roleId, role);
}
}
return Array.from(normalizedById.values()).sort(roleSortAscending);
}
export function getRoomRoleById(roles: readonly RoomRole[] | undefined, roleId: string): RoomRole | undefined {
return (roles ?? []).find((role) => role.id === roleId);
}
export function createCustomRoomRole(name: string, roles: readonly RoomRole[]): RoomRole {
const normalizedName = normalizeName(name) || 'New Role';
return {
id: `role-${crypto.randomUUID()}`,
name: normalizedName,
position: nextRolePosition(roles),
permissions: {}
};
}
export function reorderRoles(roles: readonly RoomRole[], orderedRoleIds: readonly string[]): RoomRole[] {
const roleLookup = buildRoleLookup(roles);
const systemRoles = roles.filter((role) => role.isSystem);
const customRoles = orderedRoleIds.map((roleId) => roleLookup.get(roleId)).filter((role): role is RoomRole => !!role && !role.isSystem);
const remainingCustomRoles = roles.filter((role) => !role.isSystem && !orderedRoleIds.includes(role.id));
const orderedRoles = sortRolesForDisplay(systemRoles).concat(customRoles)
.concat(sortRolesForDisplay(remainingCustomRoles));
return orderedRoles
.map((role, index) => ({
...role,
position: (orderedRoles.length - index - 1) * 100
}))
.sort(roleSortAscending);
}
export function withUpdatedRole(roles: readonly RoomRole[], roleId: string, updates: Partial<RoomRole>): RoomRole[] {
return normalizeRoomRoles(
roles.map((role) => {
if (role.id !== roleId) {
return role;
}
return {
...role,
...updates,
permissions: normalizePermissionMatrix(updates.permissions ?? role.permissions)
};
})
);
}
export function findAssignableRoles(roles: readonly RoomRole[]): RoomRole[] {
return sortRolesForDisplay(roles).filter((role) => role.id !== SYSTEM_ROLE_IDS.everyone);
}

View File

@@ -0,0 +1,209 @@
import {
PermissionState,
Room,
RoomMember,
RoomPermissionKey,
RoomPermissions,
RoomRole,
RoomRoleAssignment,
UserRole
} from '../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from './access-control.constants';
import {
getRolePermissionState,
permissionStateToBoolean,
resolveLegacyAllowState
} from './access-control.internal';
import type { MemberIdentity } from './access-control.models';
import {
getAssignedRoleIds,
normalizeRoomRoleAssignments,
removeRoleFromAssignments
} from './role-assignment.rules';
import {
getRoomRoleById,
normalizeRoomRoles,
withUpdatedRole
} from './role.rules';
import { normalizeChannelPermissionOverrides, resolveRoomPermission } from './permission.rules';
function applyRolePermissionChanges(
roles: readonly RoomRole[],
role: RoomRole | null | undefined,
changes: Partial<Record<RoomPermissionKey, PermissionState | undefined>>
): RoomRole[] {
if (!role) {
return [...roles];
}
return withUpdatedRole(roles, role.id, {
permissions: {
...role.permissions,
...changes
}
});
}
function getEveryoneLegacyPermissionChanges(
role: RoomRole,
permissions: Partial<RoomPermissions>
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
return {
joinVoice: resolveLegacyAllowState(permissions.allowVoice, role.permissions?.joinVoice, 'deny'),
shareScreen: resolveLegacyAllowState(permissions.allowScreenShare, role.permissions?.shareScreen, 'deny'),
uploadFiles: resolveLegacyAllowState(permissions.allowFileUploads, role.permissions?.uploadFiles, 'deny')
};
}
function getModeratorLegacyPermissionChanges(
role: RoomRole,
permissions: Partial<RoomPermissions>
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
return {
manageChannels: resolveLegacyAllowState(permissions.moderatorsManageRooms, role.permissions?.manageChannels, 'inherit'),
manageIcon: resolveLegacyAllowState(permissions.moderatorsManageIcon, role.permissions?.manageIcon, 'inherit')
};
}
function getAdminLegacyPermissionChanges(
role: RoomRole,
permissions: Partial<RoomPermissions>
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
return {
manageChannels: resolveLegacyAllowState(permissions.adminsManageRooms, role.permissions?.manageChannels, 'inherit'),
manageIcon: resolveLegacyAllowState(permissions.adminsManageIcon, role.permissions?.manageIcon, 'inherit')
};
}
export function resolveLegacyRole(room: Room, identity: MemberIdentity | null | undefined): UserRole {
if (!identity) {
return 'member';
}
if (room.hostId === identity.id || room.hostId === identity.oderId) {
return 'host';
}
const assignedRoleIds = getAssignedRoleIds(room.roleAssignments, identity);
if (assignedRoleIds.includes(SYSTEM_ROLE_IDS.admin)) {
return 'admin';
}
if (assignedRoleIds.includes(SYSTEM_ROLE_IDS.moderator)) {
return 'moderator';
}
if (
resolveRoomPermission(room, identity, 'manageRoles') ||
resolveRoomPermission(room, identity, 'banMembers') ||
resolveRoomPermission(room, identity, 'manageBans') ||
resolveRoomPermission(room, identity, 'manageServer')
) {
return 'admin';
}
if (
resolveRoomPermission(room, identity, 'kickMembers') ||
resolveRoomPermission(room, identity, 'deleteMessages') ||
resolveRoomPermission(room, identity, 'manageChannels') ||
resolveRoomPermission(room, identity, 'manageIcon')
) {
return 'moderator';
}
return 'member';
}
export function deriveLegacyRoomPermissions(room: Pick<Room, 'roles' | 'permissions' | 'slowModeInterval'>): RoomPermissions {
const roles = normalizeRoomRoles(room.roles, room.permissions);
const everyoneRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.everyone);
const moderatorRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.moderator);
const adminRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.admin);
return {
allowVoice: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'joinVoice'), true),
allowScreenShare: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'shareScreen'), true),
allowFileUploads: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'uploadFiles'), true),
adminsManageRooms: getRolePermissionState(adminRole, 'manageChannels') === 'allow',
moderatorsManageRooms: getRolePermissionState(moderatorRole, 'manageChannels') === 'allow',
adminsManageIcon: getRolePermissionState(adminRole, 'manageIcon') === 'allow',
moderatorsManageIcon: getRolePermissionState(moderatorRole, 'manageIcon') === 'allow',
slowModeInterval: room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0
};
}
export function withLegacyRoomPermissions(room: Room, permissions: Partial<RoomPermissions>): Room {
const roles = normalizeRoomRoles(room.roles, room.permissions);
const everyoneRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.everyone);
const moderatorRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.moderator);
const adminRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.admin);
let nextRoles = applyRolePermissionChanges(roles, everyoneRole, everyoneRole ? getEveryoneLegacyPermissionChanges(everyoneRole, permissions) : {});
nextRoles = applyRolePermissionChanges(
nextRoles,
moderatorRole,
moderatorRole ? getModeratorLegacyPermissionChanges(moderatorRole, permissions) : {}
);
nextRoles = applyRolePermissionChanges(nextRoles, adminRole, adminRole ? getAdminLegacyPermissionChanges(adminRole, permissions) : {});
return normalizeRoomAccessControl({
...room,
roles: nextRoles,
slowModeInterval: permissions.slowModeInterval ?? room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0
});
}
export function hydrateRoomMembers(room: Room): RoomMember[] {
const roles = normalizeRoomRoles(room.roles, room.permissions);
const roleAssignments = normalizeRoomRoleAssignments(room.roleAssignments, room.members, roles);
return (room.members ?? []).map((member) => {
const roleIds = getAssignedRoleIds(roleAssignments, member);
const hydratedRoom: Room = {
...room,
roles,
roleAssignments
};
return {
...member,
roleIds,
role: resolveLegacyRole(hydratedRoom, member)
};
});
}
export function normalizeRoomAccessControl(room: Room): Room {
const roles = normalizeRoomRoles(room.roles, room.permissions);
const roleAssignments = normalizeRoomRoleAssignments(room.roleAssignments, room.members, roles);
const channelPermissions = normalizeChannelPermissionOverrides(room.channelPermissions, roles);
const slowModeInterval = room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0;
const nextRoom: Room = {
...room,
roles,
roleAssignments,
channelPermissions,
slowModeInterval
};
nextRoom.permissions = deriveLegacyRoomPermissions(nextRoom);
nextRoom.members = hydrateRoomMembers(nextRoom);
return nextRoom;
}
export function removeRole(
roles: readonly RoomRole[],
assignments: readonly RoomRoleAssignment[] | undefined,
roleId: string
): { roles: RoomRole[]; roleAssignments: RoomRoleAssignment[] } {
const nextRoles = roles.filter((role) => role.id !== roleId || role.isSystem);
return {
roles: normalizeRoomRoles(nextRoles),
roleAssignments: removeRoleFromAssignments(assignments, roleId)
};
}

View File

@@ -0,0 +1,6 @@
export * from './domain/access-control.models';
export * from './domain/access-control.constants';
export * from './domain/role.rules';
export * from './domain/role-assignment.rules';
export * from './domain/permission.rules';
export * from './domain/room.rules';

View File

@@ -1,4 +1,4 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/prefer-ngsrc -->
<!-- eslint-disable @angular-eslint/template/button-has-type -->
<div #composerRoot>
@if (replyTo()) {
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">

View File

@@ -77,43 +77,17 @@
@if (msg.isDeleted) {
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
} @else {
<div class="chat-markdown mt-1 break-words">
<remark
[markdown]="msg.content"
[processor]="$any(remarkProcessor)"
>
<ng-template
[remarkTemplate]="'code'"
let-node
>
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
<remark-mermaid [code]="getMermaidCode(node.value)" />
} @else {
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
}
</ng-template>
<ng-template
[remarkTemplate]="'image'"
let-node
>
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
<img
[appChatImageProxyFallback]="node.url"
[alt]="node.alt || 'Shared image'"
class="block max-h-80 max-w-full w-auto"
loading="lazy"
/>
@if (isKlipyMediaUrl(node.url)) {
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
}
</div>
</ng-template>
</remark>
</div>
@if (requiresRichMarkdown(msg.content)) {
@defer {
<div class="chat-markdown mt-1 break-words">
<app-chat-message-markdown [content]="msg.content" />
</div>
} @placeholder {
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
}
} @else {
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
}
@if (attachmentsList.length > 0) {
<div class="mt-2 space-y-2">

View File

@@ -24,11 +24,6 @@ import {
lucideTrash2,
lucideX
} from '@ng-icons/lucide';
import { MermaidComponent, RemarkModule } from 'ngx-remark';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import {
Attachment,
AttachmentFacade,
@@ -41,7 +36,7 @@ import {
ChatVideoPlayerComponent,
UserAvatarComponent
} from '../../../../../../shared';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
import {
ChatMessageDeleteEvent,
ChatMessageEditEvent,
@@ -60,28 +55,7 @@ const COMMON_EMOJIS = [
'🔥',
'👀'
];
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
cs: 'csharp',
html: 'markup',
js: 'javascript',
md: 'markdown',
plain: 'none',
plaintext: 'none',
py: 'python',
sh: 'bash',
shell: 'bash',
svg: 'markup',
text: 'none',
ts: 'typescript',
xml: 'markup',
yml: 'yaml',
zsh: 'bash'
};
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
const REMARK_PROCESSOR = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBreaks);
const RICH_MARKDOWN_PATTERN = /(^|\n)(#{1,6}\s|>\s|[-*+]\s|\d+\.\s|```|~~~)|!\[[^\]]*\]\([^)]+\)|\[[^\]]+\]\([^)]+\)|`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|_[^_\n]+_|(?:^|\n)\|.+\|/m;
interface ChatMessageAttachmentViewModel extends Attachment {
isAudio: boolean;
@@ -101,9 +75,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
NgIcon,
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
RemarkModule,
MermaidComponent,
ChatImageProxyFallbackDirective,
ChatMessageMarkdownComponent,
UserAvatarComponent
],
viewProviders: [
@@ -136,7 +108,6 @@ export class ChatMessageItemComponent {
readonly repliedMessage = input<Message | undefined>();
readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false);
readonly remarkProcessor = REMARK_PROCESSOR;
readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>();
@@ -320,23 +291,8 @@ export class ChatMessageItemComponent {
);
}
getMermaidCode(code?: string): string {
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
}
isKlipyMediaUrl(url?: string): boolean {
if (!url)
return false;
return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url);
}
isMermaidCodeBlock(lang?: string): boolean {
return this.normalizeCodeLanguage(lang) === 'mermaid';
}
getCodeBlockClass(lang?: string): string {
return `language-${this.normalizeCodeLanguage(lang)}`;
requiresRichMarkdown(content: string): boolean {
return RICH_MARKDOWN_PATTERN.test(content);
}
formatBytes(bytes: number): string {
@@ -468,15 +424,6 @@ export class ChatMessageItemComponent {
this.downloadRequested.emit(attachment);
}
private normalizeCodeLanguage(lang?: string): string {
const normalized = (lang || '').trim().toLowerCase();
if (!normalized)
return 'none';
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
}
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
const isVideo = this.isVideoAttachment(attachment);
const isAudio = this.isAudioAttachment(attachment);

View File

@@ -0,0 +1,35 @@
<remark
[markdown]="content()"
[processor]="$any(remarkProcessor)"
>
<ng-template
[remarkTemplate]="'code'"
let-node
>
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
<remark-mermaid [code]="getMermaidCode(node.value)" />
} @else {
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
}
</ng-template>
<ng-template
[remarkTemplate]="'image'"
let-node
>
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
<img
[appChatImageProxyFallback]="node.url"
[alt]="node.alt || 'Shared image'"
class="block max-h-80 max-w-full w-auto"
loading="lazy"
/>
@if (isKlipyMediaUrl(node.url)) {
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
}
</div>
</ng-template>
</remark>

View File

@@ -0,0 +1,76 @@
import { CommonModule } from '@angular/common';
import { Component, input } from '@angular/core';
import { MermaidComponent, RemarkModule } from 'ngx-remark';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
cs: 'csharp',
html: 'markup',
js: 'javascript',
md: 'markdown',
plain: 'none',
plaintext: 'none',
py: 'python',
sh: 'bash',
shell: 'bash',
svg: 'markup',
text: 'none',
ts: 'typescript',
xml: 'markup',
yml: 'yaml',
zsh: 'bash'
};
const KLIPY_MEDIA_URL_PATTERN = /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i;
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
const REMARK_PROCESSOR = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBreaks);
@Component({
selector: 'app-chat-message-markdown',
standalone: true,
imports: [
CommonModule,
RemarkModule,
MermaidComponent,
ChatImageProxyFallbackDirective
],
templateUrl: './chat-message-markdown.component.html'
})
export class ChatMessageMarkdownComponent {
readonly content = input.required<string>();
readonly remarkProcessor = REMARK_PROCESSOR;
getMermaidCode(code?: string): string {
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
}
isKlipyMediaUrl(url?: string): boolean {
if (!url)
return false;
return KLIPY_MEDIA_URL_PATTERN.test(url);
}
isMermaidCodeBlock(lang?: string): boolean {
return this.normalizeCodeLanguage(lang) === 'mermaid';
}
getCodeBlockClass(lang?: string): string {
return `language-${this.normalizeCodeLanguage(lang)}`;
}
private normalizeCodeLanguage(lang?: string): string {
const normalized = (lang || '').trim().toLowerCase();
if (!normalized)
return 'none';
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
}
}

View File

@@ -92,6 +92,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
readonly dateSeparatorLabels = computed(() => {
const labels = new Map<number, string>();
let previousDayKey: string | null = null;
this.messages().forEach((message, index) => {

View File

@@ -1,4 +1,3 @@
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
<div
class="flex h-[min(70vh,42rem)] w-full flex-col overflow-hidden rounded-[1.65rem] border border-border/80 shadow-2xl ring-1 ring-white/5"
role="dialog"

View File

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

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, computed, inject } from '@angular/core';
import {
Component,
computed,
inject
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -16,10 +20,7 @@ import { NotificationsFacade } from '../../application/notifications.facade';
@Component({
selector: 'app-notifications-settings',
standalone: true,
imports: [
CommonModule,
NgIcon
],
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideBell,
@@ -49,10 +50,12 @@ export class NotificationsSettingsComponent {
return channels.length > 0
? channels
: [{ id: 'general',
name: 'general',
type: 'text',
position: 0 }];
: [
{ id: 'general',
name: 'general',
type: 'text',
position: 0 }
];
}
onNotificationsEnabledChange(event: Event): void {

View File

@@ -1,4 +1,9 @@
import type { Channel } from '../../../shared-kernel';
import type {
Channel,
ChannelPermissionOverride,
RoomRole,
RoomRoleAssignment
} from '../../../shared-kernel';
export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
@@ -17,6 +22,10 @@ export interface ServerInfo {
isPrivate: boolean;
tags?: string[];
channels?: Channel[];
slowModeInterval?: number;
roles?: RoomRole[];
roleAssignments?: RoomRoleAssignment[];
channelPermissions?: ChannelPermissionOverride[];
createdAt: number;
sourceId?: string;
sourceName?: string;

View File

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

View File

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

View File

@@ -1,4 +1,8 @@
import { Injectable, computed, inject } from '@angular/core';
import {
Injectable,
computed,
inject
} from '@angular/core';
import {
ThemeContainerKey,
@@ -55,4 +59,4 @@ export class LayoutSyncService {
}
}, true, `${containerKey} restored to its default layout.`);
}
}
}

View File

@@ -1,4 +1,9 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import {
Injectable,
computed,
inject,
signal
} from '@angular/core';
import type { SavedThemeSummary } from '../domain/theme.models';
import { ThemeLibraryStorageService } from '../infrastructure/theme-library.storage';
import { ThemeService } from './theme.service';
@@ -173,4 +178,4 @@ export class ThemeLibraryService {
this.selectedFileName.set(entries[0]?.fileName ?? null);
}
}
}

View File

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

View File

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

View File

@@ -1,8 +1,5 @@
import { DEFAULT_THEME_DOCUMENT } from './theme.defaults';
import {
THEME_LAYOUT_CONTAINERS,
THEME_REGISTRY
} from './theme.registry';
import { THEME_LAYOUT_CONTAINERS, THEME_REGISTRY } from './theme.registry';
import {
THEME_ANIMATION_FIELDS,
THEME_ELEMENT_STYLE_FIELDS,
@@ -168,4 +165,4 @@ export const THEME_LLM_GUIDE = [
'- Keep layout edits plausible for the declared container grid size.',
'- If a field is unsupported, omit it instead of guessing.',
'- If a section does not need changes, leave it empty rather than filling it with noise.'
].join('\n');
].join('\n');

View File

@@ -27,6 +27,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
w: 1,
h: 1 }
};
continue;
}
@@ -40,6 +41,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
w: (appShell?.columns ?? 20) - 1,
h: 1 }
};
continue;
}
@@ -51,6 +53,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
w: 4,
h: 12 }
};
continue;
}
@@ -62,6 +65,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
w: 12,
h: 12 }
};
continue;
}
@@ -87,16 +91,19 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
color: 'hsl(var(--foreground))',
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.18), transparent 34%), linear-gradient(180deg, rgba(5, 8, 15, 0.98), rgba(9, 12, 20, 1))'
};
elements['serversRail'] = {
backgroundColor: 'hsl(var(--rail-background) / 0.96)',
gradient: 'linear-gradient(180deg, rgba(10, 14, 25, 0.92), rgba(6, 9, 16, 0.98))',
boxShadow: 'inset -1px 0 0 hsl(var(--border) / 0.82), 18px 0 38px rgba(0, 0, 0, 0.22)',
backdropFilter: 'var(--theme-effect-glass-blur)'
};
elements['appWorkspace'] = {
backgroundColor: 'hsl(var(--workspace-background))',
gradient: 'radial-gradient(circle at top right, hsl(var(--surface-highlight-alt) / 0.14), transparent 30%), linear-gradient(180deg, rgba(9, 12, 21, 0.96), rgba(7, 10, 18, 1))'
};
elements['titleBar'] = {
backgroundColor: 'hsl(var(--title-bar-background) / 0.82)',
color: 'hsl(var(--foreground))',
@@ -104,6 +111,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
boxShadow: 'inset 0 -1px 0 hsl(var(--border) / 0.78), 0 12px 28px rgba(0, 0, 0, 0.18)',
backdropFilter: 'var(--theme-effect-glass-blur)'
};
elements['chatRoomChannelsPanel'] = {
backgroundColor: 'hsl(var(--panel-background) / 0.9)',
color: 'hsl(var(--foreground))',
@@ -113,6 +121,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
boxShadow: 'var(--theme-effect-soft-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)'
};
elements['chatRoomMainPanel'] = {
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
color: 'hsl(var(--foreground))',
@@ -122,6 +131,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
boxShadow: 'var(--theme-effect-panel-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)'
};
elements['chatRoomMembersPanel'] = {
backgroundColor: 'hsl(var(--panel-background-alt) / 0.92)',
color: 'hsl(var(--foreground))',
@@ -131,6 +141,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
boxShadow: 'var(--theme-effect-soft-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)'
};
elements['chatRoomEmptyState'] = {
backgroundColor: 'hsl(var(--panel-background-alt) / 0.88)',
color: 'hsl(var(--muted-foreground))',
@@ -139,6 +150,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.08), transparent 45%)',
boxShadow: 'var(--theme-effect-soft-shadow)'
};
elements['voiceWorkspace'] = {
backgroundColor: 'hsl(var(--panel-background) / 0.74)',
color: 'hsl(var(--foreground))',
@@ -148,6 +160,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
boxShadow: 'var(--theme-effect-panel-shadow)',
backdropFilter: 'var(--theme-effect-glass-blur)'
};
elements['floatingVoiceControls'] = {
backgroundColor: 'hsl(var(--panel-background-alt) / 0.94)',
color: 'hsl(var(--foreground))',
@@ -238,4 +251,4 @@ export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean {
}
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
export const DEFAULT_THEME_JSON = JSON.stringify(DEFAULT_THEME_DOCUMENT, null, 2);
export const DEFAULT_THEME_JSON = JSON.stringify(DEFAULT_THEME_DOCUMENT, null, 2);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -169,6 +169,7 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
insert: nextValue
}
});
this.isApplyingExternalValue = false;
});
@@ -203,6 +204,7 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
selection: EditorSelection.range(selectionStart, selectionEnd),
effects: EditorView.scrollIntoView(selectionStart, { y: 'center' })
});
this.editorView.focus();
}
@@ -242,4 +244,4 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
});
});
}
}
}

View File

@@ -64,7 +64,7 @@ export class ThemeSettingsComponent {
readonly animationKeys = this.theme.knownAnimationClasses;
readonly layoutContainers = this.layoutSync.containers();
readonly themeEntries = this.registry.entries();
readonly workspaceTabs: ReadonlyArray<{ key: ThemeStudioWorkspace; label: string; description: string }> = [
readonly workspaceTabs: readonly { key: ThemeStudioWorkspace; label: string; description: string }[] = [
{
key: 'editor',
label: 'JSON Editor',
@@ -129,7 +129,8 @@ export class ThemeSettingsComponent {
return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable);
});
readonly filteredEntries = computed(() => {
const query = this.explorerQuery().trim().toLowerCase();
const query = this.explorerQuery().trim()
.toLowerCase();
if (!query) {
return this.mountedEntries();
@@ -470,4 +471,4 @@ export class ThemeSettingsComponent {
return text.indexOf(`"${key}"`, sectionIndex);
}
}
}

View File

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

View File

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

View File

@@ -10,4 +10,4 @@ export * from './domain/theme.schema';
export * from './domain/theme.validation';
export { ThemeNodeDirective } from './feature/theme-node.directive';
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay.component';
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay.component';

View File

@@ -203,7 +203,7 @@ export class ThemeLibraryStorageService {
isValid: true,
modifiedAt: file.modifiedAt,
themeName: result.value.meta.name,
version: result.value.meta.version,
version: result.value.meta.version
};
} catch (error) {
return {
@@ -218,4 +218,4 @@ export class ThemeLibraryStorageService {
};
}
}
}
}

View File

@@ -1,7 +1,4 @@
import {
STORAGE_KEY_THEME_ACTIVE,
STORAGE_KEY_THEME_DRAFT
} from '../../../core/constants';
import { STORAGE_KEY_THEME_ACTIVE, STORAGE_KEY_THEME_DRAFT } from '../../../core/constants';
export interface ThemeStorageSnapshot {
activeText: string | null;
@@ -41,4 +38,4 @@ export function saveActiveThemeText(text: string): void {
export function saveDraftThemeText(text: string): void {
writeStoredThemeText(STORAGE_KEY_THEME_DRAFT, text);
}
}

View File

@@ -7,10 +7,7 @@ import { Store } from '@ngrx/store';
import { STORAGE_KEY_USER_VOLUMES } from '../../../core/constants';
import { ScreenShareFacade } from '../../../domains/screen-share';
import { User } from '../../../shared-kernel';
import {
selectAllUsers,
selectCurrentUser
} from '../../../store/users/users.selectors';
import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
import { VoiceConnectionFacade } from './voice-connection.facade';
export interface PlaybackOptions {

View File

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

View File

@@ -1,438 +0,0 @@
@if (isAdmin()) {
<div class="h-full flex flex-col bg-card">
<!-- Header -->
<div class="p-4 border-b border-border flex items-center gap-2">
<ng-icon
name="lucideShield"
class="w-5 h-5 text-primary"
/>
<h2 class="font-semibold text-foreground">Admin Panel</h2>
</div>
<!-- Tabs -->
<div class="flex border-b border-border">
<button
type="button"
(click)="activeTab.set('settings')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'settings'"
[class.border-b-2]="activeTab() === 'settings'"
[class.border-primary]="activeTab() === 'settings'"
[class.text-muted-foreground]="activeTab() !== 'settings'"
>
<ng-icon
name="lucideSettings"
class="w-4 h-4 inline mr-1"
/>
Settings
</button>
<button
type="button"
(click)="activeTab.set('members')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'members'"
[class.border-b-2]="activeTab() === 'members'"
[class.border-primary]="activeTab() === 'members'"
[class.text-muted-foreground]="activeTab() !== 'members'"
>
<ng-icon
name="lucideUsers"
class="w-4 h-4 inline mr-1"
/>
Members
</button>
<button
type="button"
(click)="activeTab.set('bans')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'bans'"
[class.border-b-2]="activeTab() === 'bans'"
[class.border-primary]="activeTab() === 'bans'"
[class.text-muted-foreground]="activeTab() !== 'bans'"
>
<ng-icon
name="lucideBan"
class="w-4 h-4 inline mr-1"
/>
Bans
</button>
<button
type="button"
(click)="activeTab.set('permissions')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'permissions'"
[class.border-b-2]="activeTab() === 'permissions'"
[class.border-primary]="activeTab() === 'permissions'"
[class.text-muted-foreground]="activeTab() !== 'permissions'"
>
<ng-icon
name="lucideShield"
class="w-4 h-4 inline mr-1"
/>
Perms
</button>
</div>
<!-- Tab Content -->
<div class="flex-1 overflow-y-auto p-4">
@switch (activeTab()) {
@case ('settings') {
<div class="space-y-6">
<h3 class="text-sm font-medium text-foreground">Room Settings</h3>
<!-- Room Name -->
<div>
<label
for="room-name-input"
class="block text-sm text-muted-foreground mb-1"
>Room Name</label
>
<input
type="text"
id="room-name-input"
[(ngModel)]="roomName"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<!-- Room Description -->
<div>
<label
for="room-description-input"
class="block text-sm text-muted-foreground mb-1"
>Description</label
>
<textarea
id="room-description-input"
[(ngModel)]="roomDescription"
rows="3"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
></textarea>
</div>
<!-- Private Room Toggle -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Private Room</p>
<p class="text-xs text-muted-foreground">Require approval to join</p>
</div>
<button
type="button"
(click)="togglePrivate()"
class="p-2 rounded-lg transition-colors"
[class.bg-primary]="isPrivate()"
[class.text-primary-foreground]="isPrivate()"
[class.bg-secondary]="!isPrivate()"
[class.text-muted-foreground]="!isPrivate()"
>
@if (isPrivate()) {
<ng-icon
name="lucideLock"
class="w-4 h-4"
/>
} @else {
<ng-icon
name="lucideUnlock"
class="w-4 h-4"
/>
}
</button>
</div>
<!-- Max Users -->
<div>
<label
for="max-users-input"
class="block text-sm text-muted-foreground mb-1"
>Max Users (0 = unlimited)</label
>
<input
type="number"
id="max-users-input"
[(ngModel)]="maxUsers"
min="0"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<!-- Save Button -->
<button
type="button"
(click)="saveSettings()"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
>
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
Save Settings
</button>
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h3 class="text-sm font-medium text-destructive mb-4">Danger Zone</h3>
<button
type="button"
(click)="confirmDeleteRoom()"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2"
>
<ng-icon
name="lucideTrash2"
class="w-4 h-4"
/>
Delete Room
</button>
</div>
</div>
}
@case ('members') {
<div class="space-y-4">
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
@if (membersFiltered().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
} @else {
@for (user of membersFiltered(); track user.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<app-user-avatar
[name]="user.displayName || '?'"
size="sm"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
@if (user.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
} @else if (user.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
} @else if (user.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
}
</div>
</div>
<!-- Role actions (only for non-hosts) -->
@if (user.role !== 'host') {
<div class="flex items-center gap-1">
<select
[ngModel]="user.role"
(ngModelChange)="changeRole(user, $event)"
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="member">Member</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
<button
type="button"
(click)="kickMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick"
>
<ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
</button>
<button
type="button"
(click)="banMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban"
>
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
</button>
</div>
}
</div>
}
}
</div>
}
@case ('bans') {
<div class="space-y-4">
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
@if (bannedUsers().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
} @else {
@for (ban of bannedUsers(); track ban.oderId) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<div class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm">
{{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">
{{ ban.displayName || 'Unknown User' }}
</p>
@if (ban.reason) {
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
}
@if (ban.expiresAt) {
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
} @else {
<p class="text-xs text-destructive">Permanent</p>
}
</div>
<button
type="button"
(click)="unbanUser(ban)"
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
>
<ng-icon
name="lucideX"
class="w-4 h-4"
/>
</button>
</div>
}
}
</div>
}
@case ('permissions') {
<div class="space-y-4">
<h3 class="text-sm font-medium text-foreground">Room Permissions</h3>
<!-- Permission Toggles -->
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow Voice Chat</p>
<p class="text-xs text-muted-foreground">Users can join voice channels</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowVoice"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow Screen Share</p>
<p class="text-xs text-muted-foreground">Users can share their screen</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowScreenShare"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow File Uploads</p>
<p class="text-xs text-muted-foreground">Users can upload files</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowFileUploads"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Slow Mode</p>
<p class="text-xs text-muted-foreground">Limit message frequency</p>
</div>
<select
[(ngModel)]="slowModeInterval"
class="px-3 py-1 bg-secondary rounded border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">Off</option>
<option value="5">5 seconds</option>
<option value="10">10 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
</select>
</div>
<!-- Management Permissions -->
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
<p class="text-xs text-muted-foreground">Allow admins to create/modify chat & voice rooms</p>
</div>
<input
type="checkbox"
[(ngModel)]="adminsManageRooms"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
<p class="text-xs text-muted-foreground">Allow moderators to create/modify chat & voice rooms</p>
</div>
<input
type="checkbox"
[(ngModel)]="moderatorsManageRooms"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
</div>
<input
type="checkbox"
[(ngModel)]="adminsManageIcon"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
</div>
<input
type="checkbox"
[(ngModel)]="moderatorsManageIcon"
class="w-4 h-4 accent-primary"
/>
</div>
</div>
<!-- Save Permissions -->
<button
type="button"
(click)="savePermissions()"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
>
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
Save Permissions
</button>
</div>
}
}
</div>
</div>
<!-- Delete Confirmation Modal -->
@if (showDeleteConfirm()) {
<app-confirm-dialog
title="Delete Room"
confirmLabel="Delete Room"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="deleteRoom()"
(cancelled)="showDeleteConfirm.set(false)"
>
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
</app-confirm-dialog>
}
} @else {
<div class="h-full flex items-center justify-center text-muted-foreground">
<p>You don't have admin permissions</p>
</div>
}

View File

@@ -1,231 +0,0 @@
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideShield,
lucideBan,
lucideUserX,
lucideSettings,
lucideUsers,
lucideTrash2,
lucideCheck,
lucideX,
lucideLock,
lucideUnlock
} from '@ng-icons/lucide';
import { UsersActions } from '../../../store/users/users.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import {
selectBannedUsers,
selectIsCurrentUserAdmin,
selectCurrentUser,
selectOnlineUsers
} from '../../../store/users/users.selectors';
import { BanEntry, User } from '../../../shared-kernel';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
@Component({
selector: 'app-admin-panel',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
lucideShield,
lucideBan,
lucideUserX,
lucideSettings,
lucideUsers,
lucideTrash2,
lucideCheck,
lucideX,
lucideLock,
lucideUnlock
})
],
templateUrl: './admin-panel.component.html'
})
/**
* Admin panel for managing room settings, members, bans, and permissions.
* Only accessible to users with admin privileges.
*/
export class AdminPanelComponent {
store = inject(Store);
currentRoom = this.store.selectSignal(selectCurrentRoom);
currentUser = this.store.selectSignal(selectCurrentUser);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
bannedUsers = this.store.selectSignal(selectBannedUsers);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
activeTab = signal<AdminTab>('settings');
showDeleteConfirm = signal(false);
// Settings
roomName = '';
roomDescription = '';
isPrivate = signal(false);
maxUsers = 0;
// Permissions
allowVoice = true;
allowScreenShare = true;
allowFileUploads = true;
slowModeInterval = '0';
adminsManageRooms = false;
moderatorsManageRooms = false;
adminsManageIcon = false;
moderatorsManageIcon = false;
private webrtc = inject(RealtimeSessionFacade);
constructor() {
// Initialize from current room
const room = this.currentRoom();
if (room) {
this.roomName = room.name;
this.roomDescription = room.description || '';
this.isPrivate.set(room.isPrivate);
this.maxUsers = room.maxUsers || 0;
const perms = room.permissions || {};
this.allowVoice = perms.allowVoice !== false;
this.allowScreenShare = perms.allowScreenShare !== false;
this.allowFileUploads = perms.allowFileUploads !== false;
this.slowModeInterval = String(perms.slowModeInterval ?? 0);
this.adminsManageRooms = !!perms.adminsManageRooms;
this.moderatorsManageRooms = !!perms.moderatorsManageRooms;
this.adminsManageIcon = !!perms.adminsManageIcon;
this.moderatorsManageIcon = !!perms.moderatorsManageIcon;
}
}
/** Toggle the room's private visibility setting. */
togglePrivate(): void {
this.isPrivate.update((current) => !current);
}
/** Save the current room name, description, privacy, and max-user settings. */
saveSettings(): void {
const room = this.currentRoom();
if (!room)
return;
this.store.dispatch(
RoomsActions.updateRoomSettings({
roomId: room.id,
settings: {
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),
maxUsers: this.maxUsers
}
})
);
}
/** Persist updated room permissions (voice, screen-share, uploads, slow-mode, role grants). */
savePermissions(): void {
const room = this.currentRoom();
if (!room)
return;
this.store.dispatch(
RoomsActions.updateRoomPermissions({
roomId: room.id,
permissions: {
allowVoice: this.allowVoice,
allowScreenShare: this.allowScreenShare,
allowFileUploads: this.allowFileUploads,
slowModeInterval: parseInt(this.slowModeInterval, 10),
adminsManageRooms: this.adminsManageRooms,
moderatorsManageRooms: this.moderatorsManageRooms,
adminsManageIcon: this.adminsManageIcon,
moderatorsManageIcon: this.moderatorsManageIcon
}
})
);
}
/** Remove a user's ban entry. */
unbanUser(ban: BanEntry): void {
this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId,
oderId: ban.oderId }));
}
/** Show the delete-room confirmation dialog. */
confirmDeleteRoom(): void {
this.showDeleteConfirm.set(true);
}
/** Delete the current room after confirmation. */
deleteRoom(): void {
const room = this.currentRoom();
if (!room)
return;
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
this.showDeleteConfirm.set(false);
}
/** Format a ban expiry timestamp into a human-readable date/time string. */
formatExpiry(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit',
minute: '2-digit' });
}
// Members tab: get all users except self
/** Return online users excluding the current user (for the members list). */
membersFiltered(): User[] {
const me = this.currentUser();
return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId);
}
/** Change a member's role and notify connected peers. */
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
const roomId = this.currentRoom()?.id;
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
role }));
this.webrtc.broadcastMessage({
type: 'role-change',
roomId,
targetUserId: user.id,
role
});
}
/** Kick a member from the server. */
kickMember(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
}
/** Ban a member from the server. */
banMember(user: User): void {
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
}
}

View File

@@ -22,17 +22,11 @@ import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messag
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
import { VoiceWorkspaceComponent } from '../voice-workspace/voice-workspace.component';
import {
selectCurrentRoom,
selectTextChannels
} from '../../../store/rooms/rooms.selectors';
import { selectCurrentRoom, selectTextChannels } from '../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { VoiceWorkspaceService } from '../../../domains/voice-session';
import {
ThemeNodeDirective,
ThemeService
} from '../../../domains/theme';
import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
@Component({
selector: 'app-chat-room',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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