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