diff --git a/packages/server/src/routes/channels.ts b/packages/server/src/routes/channels.ts index 7fbbb2da..7175fe95 100644 --- a/packages/server/src/routes/channels.ts +++ b/packages/server/src/routes/channels.ts @@ -4,6 +4,7 @@ import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { isMember, hasPermission, getChannelServerId, PermissionBits, computePermissions } from '../utils/permissions.js'; +import { permissionsToString } from '@opencord/shared/src/permissions.js'; import { connectionManager } from '../ws/handler.js'; import type { CreateChannelRequest, @@ -23,6 +24,38 @@ function rowToChannel(row: typeof schema.channels.$inferSelect): Channel { }; } +/** + * After a channel override changes, notify each server member: + * - VIEW_CHANNEL holders receive channel_updated (with their myPermissions) + * - Non-viewers receive channel_deleted to remove the channel from their UI + */ +function broadcastOverrideChange(serverId: string, channelId: string): void { + const db = getDb(); + const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get(); + if (!channel) return; + + const channelData = rowToChannel(channel); + + for (const [userId, serverIds] of connectionManager.getUserServerEntries()) { + if (!serverIds.has(serverId)) continue; + + const perms = computePermissions(userId, serverId, channelId); + if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) { + connectionManager.sendToUser(userId, { + type: 'channel_updated', + channel: { ...channelData, myPermissions: permissionsToString(perms) }, + serverId, + }); + } else { + connectionManager.sendToUser(userId, { + type: 'channel_deleted', + channelId, + serverId, + }); + } + } +} + export async function channelRoutes(app: FastifyInstance): Promise { // GET /api/servers/:id/channels - List channels in a server app.get<{ Params: { id: string } }>('/api/servers/:id/channels', { @@ -177,8 +210,8 @@ export async function channelRoutes(app: FastifyInstance): Promise { const channelData = rowToChannel(updated); - // Broadcast channel_updated to all server members - connectionManager.sendToServer(serverId, { + // Broadcast channel_updated to members with VIEW_CHANNEL + connectionManager.sendToChannel(serverId, id, { type: 'channel_updated', channel: channelData, serverId, @@ -204,16 +237,26 @@ export async function channelRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 }); } + // Collect viewers BEFORE deleting (overrides CASCADE-delete with the channel) + const viewerIds: string[] = []; + for (const [uid, serverIds] of connectionManager.getUserServerEntries()) { + if (serverIds.has(serverId)) { + const perms = computePermissions(uid, serverId, id); + if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) { + viewerIds.push(uid); + } + } + } + // Delete messages in channel (attachments cascade), then channel db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run(); db.delete(schema.channels).where(eq(schema.channels.id, id)).run(); - // Broadcast channel_deleted to all server members - connectionManager.sendToServer(serverId, { - type: 'channel_deleted', - channelId: id, - serverId, - }); + // Broadcast channel_deleted only to users who could see the channel + const deleteEvent = { type: 'channel_deleted' as const, channelId: id, serverId }; + for (const uid of viewerIds) { + connectionManager.sendToUser(uid, deleteEvent); + } return reply.code(200).send({ success: true }); }); @@ -303,6 +346,9 @@ export async function channelRoutes(app: FastifyInstance): Promise { }).run(); }); + // Notify all server members of the permission change + broadcastOverrideChange(channel.serverId, id); + return reply.code(200).send({ success: true }); }); @@ -331,6 +377,9 @@ export async function channelRoutes(app: FastifyInstance): Promise { ) ).run(); + // Notify all server members of the permission change + broadcastOverrideChange(channel.serverId, id); + return reply.code(200).send({ success: true }); }, ); diff --git a/packages/server/src/routes/servers.ts b/packages/server/src/routes/servers.ts index 465e10f2..25d88e40 100644 --- a/packages/server/src/routes/servers.ts +++ b/packages/server/src/routes/servers.ts @@ -3,8 +3,8 @@ import { eq, and, inArray } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; -import { isMember, isServerOwner, hasPermission, PermissionBits } from '../utils/permissions.js'; -import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js'; +import { isMember, isServerOwner, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js'; +import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@opencord/shared/src/permissions.js'; import crypto from 'crypto'; import { connectionManager } from '../ws/handler.js'; import type { @@ -228,9 +228,15 @@ export async function serverRoutes(app: FastifyInstance): Promise { }) .filter((m): m is MemberWithUser => m !== null); + // Filter channels by VIEW_CHANNEL permission before returning + const visibleChannels = channels.filter(ch => { + const perms = computePermissions(request.userId, id, ch.id); + return (perms & PermissionBits.VIEW_CHANNEL) !== 0n; + }); + const result: ServerWithChannelsAndMembers = { ...rowToServer(server), - channels: channels.map(rowToChannel), + channels: visibleChannels.map(rowToChannel), members, roles: roles.map(r => ({ id: r.id, @@ -518,6 +524,54 @@ export async function serverRoutes(app: FastifyInstance): Promise { )) .run(); + // Bridge legacy role string to bitwise member_roles + if (role === 'admin') { + // Find or create Admin role for this server (matches migrate.ts convention) + const adminPerms = permissionsToString(ALL_PERMISSIONS); + let adminRole = db.select().from(schema.roles) + .where(and( + eq(schema.roles.serverId, id), + eq(schema.roles.name, 'Admin'), + eq(schema.roles.permissions, adminPerms), + )) + .get(); + + if (!adminRole) { + const adminRoleId = `${id}-admin`; + db.insert(schema.roles).values({ + id: adminRoleId, + serverId: id, + name: 'Admin', + color: '#e74c3c', + position: 1, + permissions: adminPerms, + createdAt: Date.now(), + }).run(); + adminRole = db.select().from(schema.roles).where(eq(schema.roles.id, adminRoleId)).get(); + } + + if (adminRole) { + // Assign the Admin role (no-op if already assigned) + db.insert(schema.memberRoles).values({ + serverId: id, + userId: uid, + roleId: adminRole.id, + }).onConflictDoNothing().run(); + } + } else if (role === 'member') { + // Remove all explicit role assignments (demote to @everyone only) + // @everyone is implicit via computePermissions, never stored in member_roles + db.delete(schema.memberRoles) + .where(and( + eq(schema.memberRoles.serverId, id), + eq(schema.memberRoles.userId, uid), + )) + .run(); + } + + // Force target user's client to re-sync with their new permissions + connectionManager.pushReadyPayload(uid); + const updatedMember = db.select() .from(schema.serverMembers) .where(and( diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 0470211f..b3c923a2 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -210,8 +210,8 @@ function handleMessageCreate(event: Record, userId: string): vo const messageWithUser = getMessageWithUser(messageId); if (messageWithUser) { - // Broadcast to all server members (including sender) - connectionManager.sendToServer(serverId, { + // Broadcast to members with VIEW_CHANNEL on this channel + connectionManager.sendToChannel(serverId, channelId, { type: 'message_created', message: messageWithUser, }); @@ -255,7 +255,7 @@ function handleMessageEdit(event: Record, userId: string): void const updatedMessage = getMessageWithUser(messageId); if (updatedMessage) { - connectionManager.sendToServer(serverId, { + connectionManager.sendToChannel(serverId, message.channelId, { type: 'message_updated', message: updatedMessage, }); @@ -293,7 +293,7 @@ function handleMessageDelete(event: Record, userId: string): vo db.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run(); db.delete(schema.messages).where(eq(schema.messages.id, messageId)).run(); - connectionManager.sendToServer(serverId, { + connectionManager.sendToChannel(serverId, message.channelId, { type: 'message_deleted', messageId, channelId: message.channelId, @@ -317,8 +317,8 @@ function handleTypingStart(event: Record, userId: string, usern clearTimeout(existing); } - // Broadcast typing event (exclude sender) - connectionManager.sendToServer(serverId, { + // Broadcast typing event to channel viewers (exclude sender) + connectionManager.sendToChannel(serverId, channelId, { type: 'typing', channelId, userId, @@ -728,7 +728,7 @@ function handleReactionAdd(event: Record, userId: string): void createdAt: Date.now(), }).run(); - connectionManager.sendToServer(serverId, { + connectionManager.sendToChannel(serverId, message.channelId, { type: 'reaction_added', messageId, reaction: { @@ -766,7 +766,7 @@ function handleReactionRemove(event: Record, userId: string): v .run(); if (result.changes > 0) { - connectionManager.sendToServer(serverId, { + connectionManager.sendToChannel(serverId, message.channelId, { type: 'reaction_removed', messageId, userId, diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 86fd3fa4..344913cb 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -425,6 +425,29 @@ class ConnectionManager { } } + /** Send to server members who have VIEW_CHANNEL on the given channel. */ + sendToChannel(serverId: string, channelId: string, event: ServerEvent, excludeUserId?: string): void { + const message = JSON.stringify(event); + for (const [userId, serverIds] of this.userServers) { + if (serverIds.has(serverId) && userId !== excludeUserId) { + const perms = computePermissions(userId, serverId, channelId); + if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) { + const connections = this.getUserConnections(userId); + for (const ws of connections) { + if (ws.readyState === 1) { + ws.send(message); + } + } + } + } + } + } + + /** Expose userServers iterator for pre-delete viewer collection. */ + getUserServerEntries(): IterableIterator<[string, Set]> { + return this.userServers.entries(); + } + /** Send to all DM channel members (queries dm_members table). */ sendToDmMembers(dmChannelId: string, event: ServerEvent, excludeUserId?: string): void { const db = getDb(); @@ -470,6 +493,20 @@ class ConnectionManager { getAllOnlineUserIds(): string[] { return Array.from(this.connections.keys()); } + + /** Push a fresh ready payload to a specific user, forcing full store re-sync. */ + pushReadyPayload(userId: string): void { + const connections = this.getUserConnections(userId); + if (connections.size === 0) return; + + const readyData = buildReadyPayload(userId); + const message = JSON.stringify({ type: 'ready', ...readyData }); + for (const ws of connections) { + if (ws.readyState === 1) { + ws.send(message); + } + } + } } export const connectionManager = new ConnectionManager(); diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 87980f32..c2bfad66 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -134,6 +134,14 @@ export const api = { }, sendMessage: (channelId: string, data: CreateMessageRequest) => request('POST', `/channels/${channelId}/messages`, data), + getOverrides: (channelId: string) => + request<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>( + 'GET', `/channels/${channelId}/overrides` + ), + putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => + request<{ success: boolean }>('PUT', `/channels/${channelId}/overrides`, data), + deleteOverride: (channelId: string, targetType: string, targetId: string) => + request<{ success: boolean }>('DELETE', `/channels/${channelId}/overrides/${targetType}/${targetId}`), }, messages: { diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 5efcc00d..8ef0cbab 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -12,6 +12,7 @@ import { CreateChannelModal } from '../modals/CreateChannel'; import { InviteModal } from '../modals/InviteModal'; import { UserSettingsModal } from '../modals/UserSettings'; import { ServerSettingsModal } from '../modals/ServerSettings'; +import { ChannelSettingsModal } from '../modals/ChannelSettingsModal'; import { NewDmModal } from '../modals/NewDmModal'; import { AddDmMemberModal } from '../modals/AddDmMemberModal'; import { IncomingCallModal } from '../voice/IncomingCallModal'; @@ -236,6 +237,7 @@ export function AppLayout() { + diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index a33397f9..dade82cb 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -344,7 +344,22 @@ export function ChannelSidebar() { - {channel.name} + {channel.name} + {canManageChannels && ( + { + e.stopPropagation(); + openModal('channelSettings', { channelId: channel.id }); + }} + > + + + )} ); })} diff --git a/packages/web/src/components/modals/ChannelSettingsModal.tsx b/packages/web/src/components/modals/ChannelSettingsModal.tsx new file mode 100644 index 00000000..b291c0b1 --- /dev/null +++ b/packages/web/src/components/modals/ChannelSettingsModal.tsx @@ -0,0 +1,154 @@ +import React, { useState, useEffect } from 'react'; +import { Modal } from '../ui/Modal'; +import { useUIStore } from '../../stores/uiStore'; +import { useServerStore } from '../../stores/serverStore'; +import { api } from '../../api/client'; +import { PermissionBits, permissionsToString, stringToPermissions } from '../../utils/permissions'; + +interface ChannelOverride { + channelId: string; + targetType: string; + targetId: string; + allow: string; + deny: string; +} + +export function ChannelSettingsModal() { + const activeModal = useUIStore((s) => s.activeModal); + const modalData = useUIStore((s) => s.modalData); + const closeModal = useUIStore((s) => s.closeModal); + const currentServerId = useServerStore((s) => s.currentServerId); + const channels = useServerStore((s) => s.channels); + + const [isPrivate, setIsPrivate] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isFetching, setIsFetching] = useState(true); + const [error, setError] = useState(''); + + const isOpen = activeModal === 'channelSettings'; + const channelId = modalData?.channelId as string | undefined; + const channel = channels.find(c => c.id === channelId); + + // Fetch overrides when modal opens + useEffect(() => { + if (!isOpen || !channelId || !currentServerId) { + setIsFetching(false); + return; + } + + setIsFetching(true); + setError(''); + + api.channels.getOverrides(channelId) + .then((overrides: ChannelOverride[]) => { + // Check if @everyone role (id === serverId) has VIEW_CHANNEL denied + const everyoneOverride = overrides.find( + o => o.targetType === 'role' && o.targetId === currentServerId + ); + if (everyoneOverride) { + const denyBits = stringToPermissions(everyoneOverride.deny); + setIsPrivate((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n); + } else { + setIsPrivate(false); + } + }) + .catch((err: Error) => { + setError(err.message || 'Failed to load channel overrides'); + }) + .finally(() => { + setIsFetching(false); + }); + }, [isOpen, channelId, currentServerId]); + + if (!isOpen || !channel || !channelId || !currentServerId) return null; + + const handleToggle = async () => { + setError(''); + setIsLoading(true); + + try { + if (!isPrivate) { + // Make private: deny VIEW_CHANNEL for @everyone role + await api.channels.putOverride(channelId, { + targetType: 'role', + targetId: currentServerId, + allow: '0', + deny: permissionsToString(PermissionBits.VIEW_CHANNEL), + }); + setIsPrivate(true); + } else { + // Make public: remove the @everyone VIEW_CHANNEL deny override + await api.channels.deleteOverride(channelId, 'role', currentServerId); + setIsPrivate(false); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update channel privacy'); + } finally { + setIsLoading(false); + } + }; + + return ( + +
+
+ +
+ + + + {channel.name} +
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+
+
Private Channel
+
+ Only selected members and roles will be able to view this channel. +
+
+ +
+
+ + {isPrivate && !isFetching && ( +
+ + + + + This channel is hidden from members without explicit access. Users with the Administrator permission or server owners can always see all channels. + +
+ )} +
+
+ ); +} diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index bc58235c..91c1eb09 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -318,29 +318,51 @@ function handleEvent(event: ServerEvent): void { } case 'channel_created': { - const { currentServerId: curServerId, channels: curChannels, setChannels } = useServerStore.getState(); + const { currentServerId: curServerId, channels: curChannels, setChannels, channelToServerMap, channelPermissions } = useServerStore.getState(); if (event.serverId === curServerId) { // Deduplicate: only add if not already present if (!curChannels.find(c => c.id === event.channel.id)) { setChannels([...curChannels, event.channel].sort((a, b) => a.position - b.position)); } } + // Update auxiliary maps + channelToServerMap.set(event.channel.id, event.serverId); + if (event.channel.myPermissions) { + channelPermissions.set(event.channel.id, event.channel.myPermissions); + } break; } case 'channel_updated': { - const { currentServerId: curServerId2, channels: curChannels2, setChannels: setChannels2 } = useServerStore.getState(); + const { currentServerId: curServerId2, channels: curChannels2, setChannels: setChannels2, channelPermissions: chPermsMap2 } = useServerStore.getState(); if (event.serverId === curServerId2) { - setChannels2(curChannels2.map(c => c.id === event.channel.id ? event.channel : c).sort((a, b) => a.position - b.position)); + const exists = curChannels2.some(c => c.id === event.channel.id); + if (exists) { + // Replace existing channel data + setChannels2(curChannels2.map(c => c.id === event.channel.id ? event.channel : c).sort((a, b) => a.position - b.position)); + } else { + // Upsert: user just gained access to this channel + setChannels2([...curChannels2, event.channel].sort((a, b) => a.position - b.position)); + // Populate channelToServerMap for the new channel + const { channelToServerMap: ctsMmap } = useServerStore.getState(); + ctsMmap.set(event.channel.id, event.serverId); + } + } + // Sync channelPermissions with the server's computed value + if (event.channel.myPermissions) { + chPermsMap2.set(event.channel.id, event.channel.myPermissions); } break; } case 'channel_deleted': { - const { currentServerId: curServerId3, channels: curChannels3, setChannels: setChannels3 } = useServerStore.getState(); + const { currentServerId: curServerId3, channels: curChannels3, setChannels: setChannels3, channelPermissions: chPermsMap3, channelToServerMap: ctsMap3 } = useServerStore.getState(); if (event.serverId === curServerId3) { setChannels3(curChannels3.filter(c => c.id !== event.channelId)); } + // Clean up auxiliary maps + chPermsMap3.delete(event.channelId); + ctsMap3.delete(event.channelId); // If the user is currently viewing this channel, navigate away { const { currentChannelId } = useChatStore.getState(); diff --git a/packages/web/src/stores/uiStore.ts b/packages/web/src/stores/uiStore.ts index 78dfaaa4..ab616fb9 100644 --- a/packages/web/src/stores/uiStore.ts +++ b/packages/web/src/stores/uiStore.ts @@ -9,6 +9,7 @@ type ModalType = | 'invite' | 'userSettings' | 'serverSettings' + | 'channelSettings' | 'imagePreview' | 'newDm' | 'addDmMember'