diff --git a/packages/web/src/components/ui/OverrideEntry.tsx b/packages/web/src/components/ui/OverrideEntry.tsx new file mode 100644 index 00000000..f82d2ac8 --- /dev/null +++ b/packages/web/src/components/ui/OverrideEntry.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { TriStateToggle, type TriState } from './TriStateToggle'; +import { PermissionBits } from '../../utils/permissions'; + +export interface PermissionDef { + key: keyof typeof PermissionBits; + label: string; + bit: bigint; +} + +export function OverrideEntry({ + label, + color, + permDefs, + allow, + deny, + onChange, + onRemove, + isEveryone, +}: { + label: string; + color?: string; + permDefs: PermissionDef[]; + allow: bigint; + deny: bigint; + onChange: (allow: bigint, deny: bigint) => void; + onRemove?: () => void; + isEveryone?: boolean; +}) { + const [expanded, setExpanded] = useState(false); + + const getState = (bit: bigint): TriState => { + if ((allow & bit) !== 0n) return 'allow'; + if ((deny & bit) !== 0n) return 'deny'; + return 'neutral'; + }; + + const setState = (bit: bigint, state: TriState) => { + let newAllow = allow & ~bit; + let newDeny = deny & ~bit; + if (state === 'allow') newAllow |= bit; + if (state === 'deny') newDeny |= bit; + onChange(newAllow, newDeny); + }; + + // Compact summary of non-neutral permissions + const summary = permDefs.filter(p => getState(p.bit) !== 'neutral'); + + return ( +
+ + )} + + + + + {expanded && ( +
+ {permDefs.map((perm) => ( +
+ {perm.label} + setState(perm.bit, v)} + /> +
+ ))} +
+ )} +
+ ); +} diff --git a/packages/web/src/components/ui/PermissionsEditor.tsx b/packages/web/src/components/ui/PermissionsEditor.tsx new file mode 100644 index 00000000..3c23bce4 --- /dev/null +++ b/packages/web/src/components/ui/PermissionsEditor.tsx @@ -0,0 +1,569 @@ +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 && ( +
+
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/ui/TriStateToggle.tsx b/packages/web/src/components/ui/TriStateToggle.tsx new file mode 100644 index 00000000..a3709446 --- /dev/null +++ b/packages/web/src/components/ui/TriStateToggle.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +export type TriState = 'allow' | 'neutral' | 'deny'; + +export function TriStateToggle({ + value, + onChange, + disabled, +}: { + value: TriState; + onChange: (v: TriState) => void; + disabled?: boolean; +}) { + const btnClass = (v: TriState, active: boolean) => { + const base = 'w-6 h-6 flex items-center justify-center rounded-full transition-colors text-xs font-bold'; + if (disabled) return `${base} cursor-not-allowed opacity-40`; + if (!active) return `${base} cursor-pointer text-txt-muted hover:text-txt-tertiary`; + switch (v) { + case 'deny': return `${base} cursor-pointer bg-accent-rose/15 text-accent-rose`; + case 'neutral': return `${base} cursor-pointer bg-white/[0.06] text-txt-tertiary`; + case 'allow': return `${base} cursor-pointer bg-accent-primary/15 text-accent-primary`; + } + }; + + return ( +
+ + + +
+ ); +}