feat: channel permissions UI, DM Sans font, private channel filtering, migration fix

- Rewrite ChannelSettingsModal with full tri-state permission override UI
  for roles and members (allow/neutral/deny per permission bit)
- Switch font from Inter to self-hosted DM Sans (woff2 variable fonts)
- Add client-side VIEW_CHANNEL filtering in ChannelSidebar for private channels
- Broadcast isPrivate flag on channel override changes
- Fix voice permission bit migration: gate behind persistent flag to prevent
  repeated re-runs that stripped STREAM from @everyone roles
- Add speakingUserIds set to voice store for efficient user-level lookups
- Clear current channel view when a channel is deleted
- Move .glass-strip to @layer utilities for proper CSS specificity
- Simplify avatar initials font size to proportional formula
This commit is contained in:
Jannis Braun
2026-03-13 02:47:32 +01:00
parent 2b810055f7
commit 07ef49eac0
15 changed files with 1053 additions and 177 deletions
+39 -4
View File
@@ -38,6 +38,24 @@ function rowToCategory(row: typeof schema.channelCategories.$inferSelect): Chann
};
}
/**
* Check if a channel is private by looking for a VIEW_CHANNEL deny on @everyone.
* The @everyone role ID equals the space ID.
*/
function isChannelPrivate(channelId: string, spaceId: string): boolean {
const db = getDb();
const override = db.select().from(schema.channelOverrides).where(
and(
eq(schema.channelOverrides.channelId, channelId),
eq(schema.channelOverrides.targetType, 'role'),
eq(schema.channelOverrides.targetId, spaceId),
)
).get();
if (!override) return false;
const denyBits = BigInt(override.deny || '0');
return (denyBits & PermissionBits.VIEW_CHANNEL) !== 0n;
}
/**
* After a channel override changes, notify each space member:
* - VIEW_CHANNEL holders receive channel_updated (with their myPermissions)
@@ -49,6 +67,7 @@ function broadcastOverrideChange(spaceId: string, channelId: string): void {
if (!channel) return;
const channelData = rowToChannel(channel);
const priv = isChannelPrivate(channelId, spaceId);
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
if (!spaceIds.has(spaceId)) continue;
@@ -57,7 +76,7 @@ function broadcastOverrideChange(spaceId: string, channelId: string): void {
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
connectionManager.sendToUser(userId, {
type: 'channel_updated',
channel: { ...channelData, myPermissions: permissionsToString(perms) },
channel: { ...channelData, isPrivate: priv, myPermissions: permissionsToString(perms) },
spaceId,
});
} else {
@@ -184,7 +203,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
connectionManager.sendToUser(userId, {
type: 'channel_created',
channel: { ...channelData, myPermissions: permissionsToString(perms) },
channel: { ...channelData, isPrivate: false, myPermissions: permissionsToString(perms) },
spaceId: id,
});
}
@@ -400,13 +419,28 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
}
// Validate that allow/deny are valid bigint strings
let allowBits: bigint;
let denyBits: bigint;
try {
BigInt(allow || '0');
BigInt(deny || '0');
allowBits = BigInt(allow || '0');
denyBits = BigInt(deny || '0');
} catch {
return reply.code(400).send({ error: 'allow and deny must be valid decimal integer strings', statusCode: 400 });
}
// Privilege escalation guard: non-admin users can only grant permissions they possess
const callerPerms = computePermissions(request.userId, channel.spaceId);
if ((callerPerms & PermissionBits.ADMINISTRATOR) === 0n) {
const escalatedAllow = allowBits & ~callerPerms;
if (escalatedAllow !== 0n) {
return reply.code(403).send({ error: 'Cannot grant permissions you do not possess', statusCode: 403 });
}
const escalatedDeny = denyBits & ~callerPerms;
if (escalatedDeny !== 0n) {
return reply.code(403).send({ error: 'Cannot deny permissions you do not possess', statusCode: 403 });
}
}
// Upsert: delete existing then insert
db.transaction((tx) => {
tx.delete(schema.channelOverrides).where(
@@ -730,6 +764,7 @@ function broadcastChannelLayout(spaceId: string): void {
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
visibleChannels.push({
...rowToChannel(ch),
isPrivate: isChannelPrivate(ch.id, spaceId),
myPermissions: permissionsToString(perms),
});
}
+17 -1
View File
@@ -290,13 +290,29 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
// Compute space-level permissions for the requesting user
const spacePerms = computePermissions(request.userId, id);
// Batch-fetch all channel overrides for @everyone (role = spaceId) to determine isPrivate
const everyoneOverrides = db.select().from(schema.channelOverrides)
.where(and(
eq(schema.channelOverrides.targetType, 'role'),
eq(schema.channelOverrides.targetId, id),
))
.all();
const privateChannelIds = new Set<string>();
for (const o of everyoneOverrides) {
const denyBits = BigInt(o.deny || '0');
if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) {
privateChannelIds.add(o.channelId);
}
}
// Filter channels by VIEW_CHANNEL permission and attach per-channel myPermissions
const visibleChannels: (Channel & { myPermissions: string })[] = [];
const visibleChannels: (Channel & { isPrivate: boolean; myPermissions: string })[] = [];
for (const ch of channels) {
const perms = computePermissions(request.userId, id, ch.id);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
visibleChannels.push({
...rowToChannel(ch),
isPrivate: privateChannelIds.has(ch.id),
myPermissions: permissionsToString(perms),
});
}