import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useSpaceStore } from '../../stores/spaceStore'; import { permissionsToString, stringToPermissions } from '../../utils/permissions'; import { OverrideEntry, type PermissionDef } from './OverrideEntry'; import type { Role, MemberWithUser } from '@backspace/shared'; export interface Override { targetType: string; targetId: string; allow: string; deny: string; } export interface PermissionsEditorProps { entityId: string; spaceId: string; instanceOrigin?: string; permDefs: PermissionDef[]; getOverrides: () => Promise; putOverride: (data: { targetType: string; targetId: string; allow: string; deny: string }) => Promise; deleteOverride: (targetType: string, targetId: string) => Promise; } export function PermissionsEditor({ entityId, spaceId, instanceOrigin, permDefs, getOverrides, putOverride, deleteOverride, }: PermissionsEditorProps) { const roles = useSpaceStore((s) => s.roles); const members = useSpaceStore((s) => s.members); // Fetched overrides const [overrides, setOverrides] = useState([]); const [fetchError, setFetchError] = useState(''); // Draft state: keyed by "role:id" or "member:id" const [draftOverrides, setDraftOverrides] = useState>(new Map()); const [pendingRemovals, setPendingRemovals] = useState>(new Set()); const [newOverrides, setNewOverrides] = useState>(new Map()); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); // Add role/member dropdown state const [showAddRole, setShowAddRole] = useState(false); const [showAddMember, setShowAddMember] = useState(false); const [memberSearch, setMemberSearch] = useState(''); // Refs + click-outside/Escape for dropdown menus const roleDropdownRef = useRef(null); const memberDropdownRef = useRef(null); // Stable ref for the getOverrides callback to avoid re-fetch loops const getOverridesRef = useRef(getOverrides); getOverridesRef.current = getOverrides; // Fetch overrides on mount and when entityId changes const fetchOverrides = useCallback(() => { setFetchError(''); getOverridesRef.current() .then((data: Override[]) => { setOverrides(data); }) .catch((err: Error) => { setFetchError(err.message || 'Failed to load overrides'); }); }, []); useEffect(() => { fetchOverrides(); }, [entityId, fetchOverrides]); // Reset draft state when entityId changes useEffect(() => { setDraftOverrides(new Map()); setNewOverrides(new Map()); setPendingRemovals(new Set()); setSaveError(''); }, [entityId]); useEffect(() => { if (!showAddRole) return; const handleMouseDown = (e: MouseEvent) => { if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) { setShowAddRole(false); } }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowAddRole(false); }; document.addEventListener('mousedown', handleMouseDown); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('keydown', handleKeyDown); }; }, [showAddRole]); useEffect(() => { if (!showAddMember) return; const handleMouseDown = (e: MouseEvent) => { if (memberDropdownRef.current && !memberDropdownRef.current.contains(e.target as Node)) { setShowAddMember(false); setMemberSearch(''); } }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { setShowAddMember(false); setMemberSearch(''); } }; document.addEventListener('mousedown', handleMouseDown); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('keydown', handleKeyDown); }; }, [showAddMember]); // Build map of existing overrides keyed by "role:id" or "member:id" const existingOverrideMap = useMemo(() => { const map = new Map(); for (const o of overrides) { map.set(`${o.targetType}:${o.targetId}`, o); } return map; }, [overrides]); // Roles that already have overrides const existingRoleIds = useMemo(() => { const set = new Set(); for (const o of overrides) { if (o.targetType === 'role') set.add(o.targetId); } for (const [key] of newOverrides) { if (key.startsWith('role:')) set.add(key.slice(5)); } return set; }, [overrides, newOverrides]); // Members that already have overrides const existingMemberIds = useMemo(() => { const set = new Set(); for (const o of overrides) { if (o.targetType === 'member') set.add(o.targetId); } for (const [key] of newOverrides) { if (key.startsWith('member:')) set.add(key.slice(7)); } return set; }, [overrides, newOverrides]); // Available roles to add (not already in overrides) const availableRoles = useMemo(() => roles.filter(r => !existingRoleIds.has(r.id) && !pendingRemovals.has(`role:${r.id}`)), [roles, existingRoleIds, pendingRemovals]); // Available members to add (not already in overrides), filtered by search const availableMembers = useMemo(() => { const filtered = members.filter(m => !existingMemberIds.has(m.userId) && !pendingRemovals.has(`member:${m.userId}`) ); if (!memberSearch.trim()) return filtered.slice(0, 20); const q = memberSearch.toLowerCase(); return filtered.filter(m => m.user.username.toLowerCase().includes(q) || (m.user.displayName?.toLowerCase().includes(q)) ).slice(0, 20); }, [members, existingMemberIds, pendingRemovals, memberSearch]); // Get effective allow/deny for a key — considers drafts, new overrides, and originals const getEffective = useCallback((key: string): { allow: bigint; deny: bigint } => { if (newOverrides.has(key)) { const n = newOverrides.get(key)!; return { allow: n.allow, deny: n.deny }; } if (draftOverrides.has(key)) return draftOverrides.get(key)!; const orig = existingOverrideMap.get(key); if (orig) return { allow: stringToPermissions(orig.allow), deny: stringToPermissions(orig.deny) }; return { allow: 0n, deny: 0n }; }, [draftOverrides, newOverrides, existingOverrideMap]); // Update handler for an override entry const handleChange = useCallback((key: string, allow: bigint, deny: bigint) => { if (newOverrides.has(key)) { setNewOverrides(prev => { const next = new Map(prev); const entry = next.get(key)!; next.set(key, { ...entry, allow, deny }); return next; }); } else { setDraftOverrides(prev => { const next = new Map(prev); next.set(key, { allow, deny }); return next; }); } }, [newOverrides]); // Remove handler const handleRemove = useCallback((key: string) => { if (newOverrides.has(key)) { setNewOverrides(prev => { const next = new Map(prev); next.delete(key); return next; }); } else { setPendingRemovals(prev => { const next = new Set(prev); next.add(key); return next; }); // Remove from drafts too setDraftOverrides(prev => { const next = new Map(prev); next.delete(key); return next; }); } }, [newOverrides]); // Add role override const handleAddRole = useCallback((roleId: string) => { const key = `role:${roleId}`; setNewOverrides(prev => { const next = new Map(prev); next.set(key, { targetType: 'role', targetId: roleId, allow: 0n, deny: 0n }); return next; }); // If it was pending removal, unmark it setPendingRemovals(prev => { const next = new Set(prev); next.delete(key); return next; }); setShowAddRole(false); }, []); // Add member override const handleAddMember = useCallback((userId: string) => { const key = `member:${userId}`; setNewOverrides(prev => { const next = new Map(prev); next.set(key, { targetType: 'member', targetId: userId, allow: 0n, deny: 0n }); return next; }); setPendingRemovals(prev => { const next = new Set(prev); next.delete(key); return next; }); setShowAddMember(false); setMemberSearch(''); }, []); const hasChanges = draftOverrides.size > 0 || newOverrides.size > 0 || pendingRemovals.size > 0; const handleDiscard = useCallback(() => { setDraftOverrides(new Map()); setNewOverrides(new Map()); setPendingRemovals(new Set()); setSaveError(''); }, []); const handleSave = useCallback(async () => { setSaving(true); setSaveError(''); try { const promises: Promise[] = []; // Delete removed overrides for (const key of pendingRemovals) { const parts = key.split(':'); promises.push(deleteOverride(parts[0]!, parts[1]!)); } // Update modified existing overrides for (const [key, { allow, deny }] of draftOverrides) { if (pendingRemovals.has(key)) continue; const parts = key.split(':'); promises.push(putOverride({ targetType: parts[0]!, targetId: parts[1]!, allow: permissionsToString(allow), deny: permissionsToString(deny), })); } // Create new overrides for (const [, { targetType, targetId, allow, deny }] of newOverrides) { promises.push(putOverride({ targetType, targetId, allow: permissionsToString(allow), deny: permissionsToString(deny), })); } const results = await Promise.allSettled(promises); const failures = results.filter(r => r.status === 'rejected'); if (failures.length > 0) { const first = failures[0] as PromiseRejectedResult; setSaveError(first.reason?.message || `${failures.length} override(s) failed to save`); } // Reset draft state and re-fetch overrides setDraftOverrides(new Map()); setNewOverrides(new Map()); setPendingRemovals(new Set()); fetchOverrides(); } catch (err) { setSaveError(err instanceof Error ? err.message : 'Failed to save overrides'); } finally { setSaving(false); } }, [draftOverrides, newOverrides, pendingRemovals, deleteOverride, putOverride, fetchOverrides]); // Build ordered lists of role and member overrides const roleOverrides = useMemo(() => { const items: { key: string; role: Role; isNew: boolean }[] = []; // Existing overrides (excluding pending removals) for (const o of overrides) { if (o.targetType !== 'role') continue; const key = `role:${o.targetId}`; if (pendingRemovals.has(key)) continue; const role = roles.find(r => r.id === o.targetId); if (!role) continue; items.push({ key, role, isNew: false }); } // New overrides for (const [key, entry] of newOverrides) { if (!key.startsWith('role:')) continue; const role = roles.find(r => r.id === entry.targetId); if (!role) continue; if (items.some(i => i.key === key)) continue; items.push({ key, role, isNew: true }); } // Sort: @everyone first, then by position items.sort((a, b) => { if (a.role.id === spaceId) return -1; if (b.role.id === spaceId) return 1; return (a.role.position ?? 0) - (b.role.position ?? 0); }); return items; }, [overrides, newOverrides, pendingRemovals, roles, spaceId]); const memberOverrides = useMemo(() => { const items: { key: string; member: MemberWithUser; isNew: boolean }[] = []; for (const o of overrides) { if (o.targetType !== 'member') continue; const key = `member:${o.targetId}`; if (pendingRemovals.has(key)) continue; const member = members.find(m => m.userId === o.targetId); if (!member) continue; items.push({ key, member, isNew: false }); } for (const [key, entry] of newOverrides) { if (!key.startsWith('member:')) continue; const member = members.find(m => m.userId === entry.targetId); if (!member) continue; if (items.some(i => i.key === key)) continue; items.push({ key, member, isNew: true }); } return items; }, [overrides, newOverrides, pendingRemovals, members]); return (
{/* Fetch error */} {fetchError && (
{fetchError}
)} {/* Role Overrides */}
Role Overrides
{roleOverrides.map(({ key, role }) => { const eff = getEffective(key); return ( handleChange(key, a, d)} onRemove={() => handleRemove(key)} isEveryone={role.id === spaceId} /> ); })}
{/* Add Role */}
{!showAddRole ? ( ) : (
{availableRoles.length === 0 ? (
No more roles to add
) : ( availableRoles.map(role => ( )) )}
)}
{/* Member Overrides */}
Member Overrides
{memberOverrides.map(({ key, member }) => { const eff = getEffective(key); return ( handleChange(key, a, d)} onRemove={() => handleRemove(key)} /> ); })}
{/* Add Member */}
{!showAddMember ? ( ) : (
setMemberSearch(e.target.value)} placeholder="Search members..." className="input-search w-full mb-1" autoFocus />
{availableMembers.length === 0 ? (
No members found
) : ( availableMembers.map(member => ( )) )}
)}
{/* Error */} {saveError && (
{saveError}
)} {/* Save/Discard pill */} {hasChanges && (
)}
); }