feat: bitwise RBAC engine with channel-level permission overrides
Replace string-based role checks (role === 'admin') with a bitwise BigInt permission system. Adds computePermissions() resolution engine following Discord's model: @everyone base → role union → admin shortcut → channel overrides (role deny/allow → member deny/allow). Ready payload now filters channels by VIEW_CHANNEL and attaches per-user myPermissions to each server and channel. Includes channel_overrides table, @everyone role auto-creation, migration for existing servers, and override CRUD API.
This commit is contained in:
@@ -8,6 +8,7 @@ import { useChatStore } from '../../stores/chatStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Embed } from './Embed';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
|
||||
interface MessageProps {
|
||||
message: MessageWithUser;
|
||||
@@ -47,9 +48,10 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
|
||||
const channelKey = message.channelId || (message as any).dmChannelId;
|
||||
const isAuthor = currentUser?.id === message.userId;
|
||||
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
|
||||
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
|
||||
const canDelete = isAuthor || isAdminUser;
|
||||
const channelPermissions = useServerStore((s) => s.channelPermissions);
|
||||
const myChPerms = channelPermissions.get(message.channelId);
|
||||
const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES);
|
||||
const canDelete = isAuthor || canManageMessages;
|
||||
|
||||
const addReaction = useChatStore((s) => s.addReaction);
|
||||
const removeReaction = useChatStore((s) => s.removeReaction);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Avatar } from '../ui/Avatar';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { AudioManager } from '../../audio/AudioManager';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
|
||||
export function ChannelSidebar() {
|
||||
const servers = useServerStore((s) => s.servers);
|
||||
@@ -65,9 +66,10 @@ export function ChannelSidebar() {
|
||||
}
|
||||
};
|
||||
|
||||
const serverPermissions = useServerStore((s) => s.serverPermissions);
|
||||
const server = servers.find(s => s.id === currentServerId);
|
||||
const currentMember = members.find(m => m.userId === user?.id);
|
||||
const isAdminUser = currentMember?.role === 'admin' || currentMember?.role === 'owner';
|
||||
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
|
||||
const canManageChannels = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_CHANNELS);
|
||||
|
||||
const textChannels = channels.filter(c => c.type === 'text');
|
||||
const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video');
|
||||
@@ -306,7 +308,7 @@ export function ChannelSidebar() {
|
||||
</svg>
|
||||
<span className="text-[12px] font-bold uppercase tracking-wider">Text Channels</span>
|
||||
</div>
|
||||
{isAdminUser && (
|
||||
{canManageChannels && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -358,7 +360,7 @@ export function ChannelSidebar() {
|
||||
</svg>
|
||||
<span className="text-[12px] font-bold uppercase tracking-wider">Voice Channels</span>
|
||||
</div>
|
||||
{isAdminUser && (
|
||||
{canManageChannels && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Avatar } from '../ui/Avatar';
|
||||
import { api } from '../../api/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { MemberRole } from '@opencord/shared';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
|
||||
export function ServerSettingsModal() {
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
@@ -26,9 +27,13 @@ export function ServerSettingsModal() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const serverPermissions = useServerStore((s) => s.serverPermissions);
|
||||
|
||||
const isOpen = activeModal === 'serverSettings';
|
||||
const server = servers.find(s => s.id === currentServerId);
|
||||
const isOwnerUser = server?.ownerId === currentUser?.id;
|
||||
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
|
||||
const canManageServer = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_SERVER);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (server) {
|
||||
@@ -122,11 +127,11 @@ export function ServerSettingsModal() {
|
||||
value={serverName}
|
||||
onChange={(e) => setServerName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
|
||||
disabled={!isOwnerUser}
|
||||
disabled={!canManageServer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOwnerUser && (
|
||||
{canManageServer && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -12,6 +12,8 @@ interface ServerState {
|
||||
dmChannels: DmChannel[];
|
||||
channelToServerMap: Map<string, string>;
|
||||
channelLastMessageIds: Map<string, string>;
|
||||
serverPermissions: Map<string, string>; // serverId → myPermissions decimal string
|
||||
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
|
||||
setServers: (servers: Server[]) => void;
|
||||
setCurrentServer: (serverId: string | null) => void;
|
||||
setChannels: (channels: Channel[]) => void;
|
||||
@@ -52,6 +54,8 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
||||
dmChannels: [],
|
||||
channelToServerMap: new Map(),
|
||||
channelLastMessageIds: new Map(),
|
||||
serverPermissions: new Map(),
|
||||
channelPermissions: new Map(),
|
||||
|
||||
setServers: (servers) => set({ servers }),
|
||||
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
|
||||
@@ -225,15 +229,24 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
||||
createdAt: s.createdAt,
|
||||
}));
|
||||
|
||||
// Build channel→server map and channel→lastMessageId map
|
||||
// Build channel→server map, channel→lastMessageId map, and permission maps
|
||||
const channelToServerMap = new Map<string, string>();
|
||||
const channelLastMessageIds = new Map<string, string>();
|
||||
const serverPermissions = new Map<string, string>();
|
||||
const channelPermissions = new Map<string, string>();
|
||||
|
||||
for (const srv of servers) {
|
||||
if (srv.myPermissions) {
|
||||
serverPermissions.set(srv.id, srv.myPermissions);
|
||||
}
|
||||
for (const ch of srv.channels) {
|
||||
channelToServerMap.set(ch.id, srv.id);
|
||||
if (ch.lastMessageId) {
|
||||
channelLastMessageIds.set(ch.id, ch.lastMessageId);
|
||||
}
|
||||
if (ch.myPermissions) {
|
||||
channelPermissions.set(ch.id, ch.myPermissions);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also map DM channels
|
||||
@@ -250,6 +263,8 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
||||
dmChannels: dms,
|
||||
channelToServerMap,
|
||||
channelLastMessageIds,
|
||||
serverPermissions,
|
||||
channelPermissions,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Frontend permission helpers — wraps shared permission constants.
|
||||
// Always works with string representations (never raw bigint in state).
|
||||
|
||||
export {
|
||||
PermissionBits,
|
||||
ALL_PERMISSIONS,
|
||||
DEFAULT_EVERYONE_PERMISSIONS,
|
||||
hasPermissionBit,
|
||||
permissionsToString,
|
||||
stringToPermissions,
|
||||
} from '@opencord/shared/src/permissions';
|
||||
Reference in New Issue
Block a user