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:
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user