Add diagram sharing/collaborators, version history, and view-only lockdown
Per organized-ideas.md §8. Backend tables/RLS (diagrams, diagram_collaborators, diagram_snapshots) already existed from an earlier phase — this is the frontend for them, plus two small backend additions. Backend (supabase/migrations/20260913000000_diagram_sharing.sql): - find_user_id_by_username(text): lets any authenticated user resolve a username to an id for "share with @username" — unlike general profile browsing (blocked by profiles_select_self_or_super_admin), a username is meant to be a shareable handle, so this is deliberately not gated. - diagram_collaborator_usernames / diagram_snapshot_saved_by_usernames: same pattern as the admin-review-queue phase's submitter-username lookup — batched per diagram, gated to "can you see this diagram at all" (reusing diagrams_select's own helper functions). - prune_diagram_snapshots trigger: keeps the 50 most recent snapshots per diagram, enforced at write time rather than a scheduled job (diagram_ snapshots has no update/delete policy for regular users at all). - 14 new pgTAP tests (52/52 total). Frontend: - DiagramCollaboratorRepository/store + DiagramSharingModal: add/remove collaborators by username, per-person view/edit permission, owner-only controls. - DiagramSnapshotRepository/store + VersionHistoryModal (its own top-bar button, not nested under Share — moved there after review): periodic checkpoints (one per 5 min of active editing) written as a side effect of normal saves, list + restore. - Restore's duplicate-snapshot problem: repeatedly jumping between old versions without editing in between was writing a near-duplicate safety snapshot on every jump. Fixed by having projectStore track which snapshot the diagram was last restored from and its updatedAt at that moment (touch() always advances updatedAt on a genuine edit) — a restore skips the safety snapshot when nothing has changed since the last one, and the tracking clears on any real edit so in-progress work stays protected. - DiagramRepository gains getAccess() (owner id + your own permission for the open diagram) — surfaced in projectStore as `access`. - View-only enforcement: FlowCanvas disables drag/connect/drop (nodesDraggable/nodesConnectable + guarded handlers), DeviceInspector/ ConnectionInspector wrap their controls in a disabled <fieldset>, DevicePalette disables adding devices to the canvas, TopBar disables the rename field, and a ViewOnlyBanner makes the restriction visible instead of leaving a collaborator to discover it as controls that just don't work. Autosave itself also refuses to write for a view-only user, as a backstop behind the UI-level lockdown. Verified: tsc -b and oxlint clean; supabase db reset + 52/52 pgTAP tests pass; confirmed find_user_id_by_username works through the real REST API via a live curl call (signup, confirm, resolve). Manually tested two- account sharing (view vs. edit), restoring history, and the duplicate- snapshot fix.
This commit is contained in:
@@ -40,6 +40,13 @@ function FlowCanvasInner() {
|
|||||||
const selectConnection = useProjectStore((s) => s.selectConnection)
|
const selectConnection = useProjectStore((s) => s.selectConnection)
|
||||||
const { screenToFlowPosition } = useReactFlow()
|
const { screenToFlowPosition } = useReactFlow()
|
||||||
const catalog = useCatalogStore((s) => s.catalog)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
|
const access = useProjectStore((s) => s.access)
|
||||||
|
// Owner/edit-collaborator/(a Super Admin, who getAccess always reports as
|
||||||
|
// 'edit') can edit; a view-only collaborator can look but not touch —
|
||||||
|
// organized-ideas.md §8's per-collaborator permissions. `access` is only
|
||||||
|
// null in the brief window before the first diagram finishes loading, at
|
||||||
|
// which point nothing is rendered yet anyway (AppShell's isLoaded gate).
|
||||||
|
const canEdit = access?.myPermission !== 'view'
|
||||||
|
|
||||||
const portTypes = useMemo(() => allPortTypes(catalog), [catalog])
|
const portTypes = useMemo(() => allPortTypes(catalog), [catalog])
|
||||||
const cableTypes = useMemo(() => allCableTypes(catalog), [catalog])
|
const cableTypes = useMemo(() => allCableTypes(catalog), [catalog])
|
||||||
@@ -93,7 +100,7 @@ function FlowCanvasInner() {
|
|||||||
const c = edgeOrConnection as Connection
|
const c = edgeOrConnection as Connection
|
||||||
const sourcePortId = parsePortId(c.sourceHandle)
|
const sourcePortId = parsePortId(c.sourceHandle)
|
||||||
const targetPortId = parsePortId(c.targetHandle)
|
const targetPortId = parsePortId(c.targetHandle)
|
||||||
if (!c.source || !c.target || !sourcePortId || !targetPortId) return false
|
if (!canEdit || !c.source || !c.target || !sourcePortId || !targetPortId) return false
|
||||||
return validateConnection(project, catalog, {
|
return validateConnection(project, catalog, {
|
||||||
sourceDeviceId: c.source,
|
sourceDeviceId: c.source,
|
||||||
sourcePortId,
|
sourcePortId,
|
||||||
@@ -101,11 +108,12 @@ function FlowCanvasInner() {
|
|||||||
targetPortId,
|
targetPortId,
|
||||||
}).valid
|
}).valid
|
||||||
},
|
},
|
||||||
[project, catalog],
|
[project, catalog, canEdit],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(connection: Connection) => {
|
(connection: Connection) => {
|
||||||
|
if (!canEdit) return
|
||||||
const sourcePortId = parsePortId(connection.sourceHandle)
|
const sourcePortId = parsePortId(connection.sourceHandle)
|
||||||
const targetPortId = parsePortId(connection.targetHandle)
|
const targetPortId = parsePortId(connection.targetHandle)
|
||||||
if (!connection.source || !connection.target || !sourcePortId || !targetPortId) return
|
if (!connection.source || !connection.target || !sourcePortId || !targetPortId) return
|
||||||
@@ -126,11 +134,12 @@ function FlowCanvasInner() {
|
|||||||
cableTypeId: defaultCableType.id,
|
cableTypeId: defaultCableType.id,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[project, catalog, addConnection],
|
[project, catalog, canEdit, addConnection],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onNodesChange = useCallback(
|
const onNodesChange = useCallback(
|
||||||
(changes: NodeChange<DeviceFlowNode>[]) => {
|
(changes: NodeChange<DeviceFlowNode>[]) => {
|
||||||
|
if (!canEdit) return
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
if (change.type === 'position' && change.position) {
|
if (change.type === 'position' && change.position) {
|
||||||
updateDevicePosition(change.id, change.position)
|
updateDevicePosition(change.id, change.position)
|
||||||
@@ -139,33 +148,38 @@ function FlowCanvasInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateDevicePosition, removeDevice],
|
[canEdit, updateDevicePosition, removeDevice],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onEdgesChange = useCallback(
|
const onEdgesChange = useCallback(
|
||||||
(changes: EdgeChange<CableFlowEdge>[]) => {
|
(changes: EdgeChange<CableFlowEdge>[]) => {
|
||||||
|
if (!canEdit) return
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
if (change.type === 'remove') removeConnection(change.id)
|
if (change.type === 'remove') removeConnection(change.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[removeConnection],
|
[canEdit, removeConnection],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
const onDragOver = useCallback(
|
||||||
if (!event.dataTransfer.types.includes(TEMPLATE_DRAG_MIME)) return
|
(event: React.DragEvent) => {
|
||||||
|
if (!canEdit || !event.dataTransfer.types.includes(TEMPLATE_DRAG_MIME)) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.dataTransfer.dropEffect = 'move'
|
event.dataTransfer.dropEffect = 'move'
|
||||||
}, [])
|
},
|
||||||
|
[canEdit],
|
||||||
|
)
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(event: React.DragEvent) => {
|
(event: React.DragEvent) => {
|
||||||
|
if (!canEdit) return
|
||||||
const templateId = event.dataTransfer.getData(TEMPLATE_DRAG_MIME)
|
const templateId = event.dataTransfer.getData(TEMPLATE_DRAG_MIME)
|
||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
|
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
|
||||||
addDeviceFromTemplate(catalog, templateId, position)
|
addDeviceFromTemplate(catalog, templateId, position)
|
||||||
},
|
},
|
||||||
[screenToFlowPosition, catalog, addDeviceFromTemplate],
|
[screenToFlowPosition, catalog, canEdit, addDeviceFromTemplate],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -179,6 +193,8 @@ function FlowCanvasInner() {
|
|||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
|
nodesDraggable={canEdit}
|
||||||
|
nodesConnectable={canEdit}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
onNodeClick={(_, node) => selectDevice(node.id)}
|
onNodeClick={(_, node) => selectDevice(node.id)}
|
||||||
onEdgeClick={(_, edge) => selectConnection(edge.id)}
|
onEdgeClick={(_, edge) => selectConnection(edge.id)}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useProjectStore } from '../../state/projectStore'
|
|||||||
export default function ConnectionInspector({ connectionId }: { connectionId: string }) {
|
export default function ConnectionInspector({ connectionId }: { connectionId: string }) {
|
||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
const catalog = useCatalogStore((s) => s.catalog)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
|
const access = useProjectStore((s) => s.access)
|
||||||
|
const canEdit = access?.myPermission !== 'view'
|
||||||
const updateConnection = useProjectStore((s) => s.updateConnection)
|
const updateConnection = useProjectStore((s) => s.updateConnection)
|
||||||
const removeConnection = useProjectStore((s) => s.removeConnection)
|
const removeConnection = useProjectStore((s) => s.removeConnection)
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
|
|||||||
if (!connection || !sourceDevice || !targetDevice || !sourcePort || !targetPort) return null
|
if (!connection || !sourceDevice || !targetDevice || !sourcePort || !targetPort) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 p-3">
|
<fieldset disabled={!canEdit} className="m-0 space-y-3 border-0 p-3">
|
||||||
<div className="rounded border border-slate-200 bg-slate-50 p-2 text-xs">
|
<div className="rounded border border-slate-200 bg-slate-50 p-2 text-xs">
|
||||||
<div className="flex items-center justify-between py-0.5">
|
<div className="flex items-center justify-between py-0.5">
|
||||||
<span className="text-slate-500">From</span>
|
<span className="text-slate-500">From</span>
|
||||||
@@ -132,6 +134,6 @@ export default function ConnectionInspector({ connectionId }: { connectionId: st
|
|||||||
Remove cable
|
Remove cable
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import CategorySelect from '../common/CategorySelect'
|
|||||||
export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
const catalog = useCatalogStore((s) => s.catalog)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
|
const access = useProjectStore((s) => s.access)
|
||||||
|
const canEdit = access?.myPermission !== 'view'
|
||||||
const updateDevice = useProjectStore((s) => s.updateDevice)
|
const updateDevice = useProjectStore((s) => s.updateDevice)
|
||||||
const removeDevice = useProjectStore((s) => s.removeDevice)
|
const removeDevice = useProjectStore((s) => s.removeDevice)
|
||||||
const addPort = useProjectStore((s) => s.addPort)
|
const addPort = useProjectStore((s) => s.addPort)
|
||||||
@@ -29,7 +31,7 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
|||||||
project.connections.filter((c) => c.sourcePortId === portId || c.targetPortId === portId).length
|
project.connections.filter((c) => c.sourcePortId === portId || c.targetPortId === portId).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 p-3">
|
<fieldset disabled={!canEdit} className="m-0 space-y-3 border-0 p-3">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<label className="col-span-2 text-xs font-medium text-slate-600">
|
<label className="col-span-2 text-xs font-medium text-slate-600">
|
||||||
Name
|
Name
|
||||||
@@ -172,6 +174,6 @@ export default function DeviceInspector({ deviceId }: { deviceId: string }) {
|
|||||||
Delete device
|
Delete device
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import DevicePalette from '../palette/DevicePalette'
|
|||||||
import ConnectionErrorBanner from './ConnectionErrorBanner'
|
import ConnectionErrorBanner from './ConnectionErrorBanner'
|
||||||
import RightPanel from './RightPanel'
|
import RightPanel from './RightPanel'
|
||||||
import TopBar from './TopBar'
|
import TopBar from './TopBar'
|
||||||
|
import ViewOnlyBanner from './ViewOnlyBanner'
|
||||||
|
|
||||||
export default function AppShell() {
|
export default function AppShell() {
|
||||||
const isLoaded = useProjectStore((s) => s.isLoaded)
|
const isLoaded = useProjectStore((s) => s.isLoaded)
|
||||||
@@ -45,6 +46,7 @@ export default function AppShell() {
|
|||||||
<DevicePalette />
|
<DevicePalette />
|
||||||
<main className="relative min-w-0 flex-1">
|
<main className="relative min-w-0 flex-1">
|
||||||
<ConnectionErrorBanner />
|
<ConnectionErrorBanner />
|
||||||
|
<ViewOnlyBanner />
|
||||||
<FlowCanvas />
|
<FlowCanvas />
|
||||||
</main>
|
</main>
|
||||||
<RightPanel />
|
<RightPanel />
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useAuthStore } from '../../state/authStore'
|
|||||||
import { useProjectStore } from '../../state/projectStore'
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
import { useSubmissionStore } from '../../state/submissionStore'
|
import { useSubmissionStore } from '../../state/submissionStore'
|
||||||
import AdminUsersModal from '../admin/AdminUsersModal'
|
import AdminUsersModal from '../admin/AdminUsersModal'
|
||||||
|
import DiagramSharingModal from '../sharing/DiagramSharingModal'
|
||||||
|
import VersionHistoryModal from '../sharing/VersionHistoryModal'
|
||||||
import AdminReviewModal from '../submissions/AdminReviewModal'
|
import AdminReviewModal from '../submissions/AdminReviewModal'
|
||||||
import MySubmissionsModal from '../submissions/MySubmissionsModal'
|
import MySubmissionsModal from '../submissions/MySubmissionsModal'
|
||||||
import DiagramManagerModal from './DiagramManagerModal'
|
import DiagramManagerModal from './DiagramManagerModal'
|
||||||
@@ -32,6 +34,8 @@ function setLastSeenSubmissionsAt(iso: string): void {
|
|||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
|
const access = useProjectStore((s) => s.access)
|
||||||
|
const canEdit = access?.myPermission !== 'view'
|
||||||
const renameProject = useProjectStore((s) => s.renameProject)
|
const renameProject = useProjectStore((s) => s.renameProject)
|
||||||
const importProject = useProjectStore((s) => s.importProject)
|
const importProject = useProjectStore((s) => s.importProject)
|
||||||
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
|
const mySubmissions = useSubmissionStore((s) => s.mySubmissions)
|
||||||
@@ -42,6 +46,8 @@ export default function TopBar() {
|
|||||||
const adminPendingCount = useAdminReviewStore((s) => s.allSubmissions.filter((sub) => sub.status === 'pending').length)
|
const adminPendingCount = useAdminReviewStore((s) => s.allSubmissions.filter((sub) => sub.status === 'pending').length)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
|
const [diagramManagerOpen, setDiagramManagerOpen] = useState(false)
|
||||||
|
const [versionHistoryOpen, setVersionHistoryOpen] = useState(false)
|
||||||
|
const [sharingOpen, setSharingOpen] = useState(false)
|
||||||
const [submissionsOpen, setSubmissionsOpen] = useState(false)
|
const [submissionsOpen, setSubmissionsOpen] = useState(false)
|
||||||
const [adminReviewOpen, setAdminReviewOpen] = useState(false)
|
const [adminReviewOpen, setAdminReviewOpen] = useState(false)
|
||||||
const [adminUsersOpen, setAdminUsersOpen] = useState(false)
|
const [adminUsersOpen, setAdminUsersOpen] = useState(false)
|
||||||
@@ -84,7 +90,8 @@ export default function TopBar() {
|
|||||||
<input
|
<input
|
||||||
value={project.name}
|
value={project.name}
|
||||||
onChange={(e) => renameProject(e.target.value)}
|
onChange={(e) => renameProject(e.target.value)}
|
||||||
className="rounded border border-transparent px-2 py-1 text-sm text-slate-700 hover:border-slate-200 focus:border-slate-300 focus:outline-none"
|
disabled={!canEdit}
|
||||||
|
className="rounded border border-transparent px-2 py-1 text-sm text-slate-700 hover:border-slate-200 focus:border-slate-300 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -94,6 +101,18 @@ export default function TopBar() {
|
|||||||
>
|
>
|
||||||
Diagrams
|
Diagrams
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setVersionHistoryOpen(true)}
|
||||||
|
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSharingOpen(true)}
|
||||||
|
className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
<button onClick={openMySubmissions} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
<button onClick={openMySubmissions} className="rounded px-2.5 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
My Submissions
|
My Submissions
|
||||||
{unseenOutcomeCount > 0 ? (
|
{unseenOutcomeCount > 0 ? (
|
||||||
@@ -151,6 +170,8 @@ export default function TopBar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
|
{diagramManagerOpen && <DiagramManagerModal onClose={() => setDiagramManagerOpen(false)} />}
|
||||||
|
{versionHistoryOpen && <VersionHistoryModal onClose={() => setVersionHistoryOpen(false)} />}
|
||||||
|
{sharingOpen && <DiagramSharingModal onClose={() => setSharingOpen(false)} />}
|
||||||
{submissionsOpen && <MySubmissionsModal onClose={() => setSubmissionsOpen(false)} />}
|
{submissionsOpen && <MySubmissionsModal onClose={() => setSubmissionsOpen(false)} />}
|
||||||
{adminReviewOpen && <AdminReviewModal onClose={() => setAdminReviewOpen(false)} />}
|
{adminReviewOpen && <AdminReviewModal onClose={() => setAdminReviewOpen(false)} />}
|
||||||
{adminUsersOpen && <AdminUsersModal onClose={() => setAdminUsersOpen(false)} />}
|
{adminUsersOpen && <AdminUsersModal onClose={() => setAdminUsersOpen(false)} />}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
|
|
||||||
|
/** Shown when the current user only has view access to the open diagram
|
||||||
|
* (organized-ideas.md §8's per-collaborator permissions) — the canvas and
|
||||||
|
* inspectors disable their edit controls in this case too (see
|
||||||
|
* FlowCanvas/DeviceInspector/ConnectionInspector), this just makes that
|
||||||
|
* visible instead of leaving it to be discovered by a control not working. */
|
||||||
|
export default function ViewOnlyBanner() {
|
||||||
|
const access = useProjectStore((s) => s.access)
|
||||||
|
if (access?.myPermission !== 'view') return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center">
|
||||||
|
<div className="pointer-events-auto rounded border border-slate-300 bg-white px-3 py-1.5 text-xs text-slate-600 shadow-sm">
|
||||||
|
View only — you don't have edit access to this diagram.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ type EditorTarget =
|
|||||||
|
|
||||||
export default function DevicePalette() {
|
export default function DevicePalette() {
|
||||||
const project = useProjectStore((s) => s.project)
|
const project = useProjectStore((s) => s.project)
|
||||||
|
const access = useProjectStore((s) => s.access)
|
||||||
|
const canEdit = access?.myPermission !== 'view'
|
||||||
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
|
const addDeviceFromTemplate = useProjectStore((s) => s.addDeviceFromTemplate)
|
||||||
const catalog = useCatalogStore((s) => s.catalog)
|
const catalog = useCatalogStore((s) => s.catalog)
|
||||||
const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds)
|
const hiddenPublicIds = useCatalogStore((s) => s.hiddenPublicDeviceTemplateIds)
|
||||||
@@ -71,11 +73,13 @@ export default function DevicePalette() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => {
|
const handleDragStart = (event: React.DragEvent, template: DeviceTemplate) => {
|
||||||
|
if (!canEdit) return
|
||||||
event.dataTransfer.setData(TEMPLATE_DRAG_MIME, template.id)
|
event.dataTransfer.setData(TEMPLATE_DRAG_MIME, template.id)
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleQuickAdd = (template: DeviceTemplate) => {
|
const handleQuickAdd = (template: DeviceTemplate) => {
|
||||||
|
if (!canEdit) return
|
||||||
// Cascade placement so repeated quick-adds don't stack exactly on top of each other.
|
// Cascade placement so repeated quick-adds don't stack exactly on top of each other.
|
||||||
const offset = (project.devices.length % 8) * 24
|
const offset = (project.devices.length % 8) * 24
|
||||||
addDeviceFromTemplate(catalog, template.id, { x: 80 + offset, y: 80 + offset })
|
addDeviceFromTemplate(catalog, template.id, { x: 80 + offset, y: 80 + offset })
|
||||||
@@ -163,9 +167,9 @@ export default function DevicePalette() {
|
|||||||
{group.templates.map((template) => (
|
{group.templates.map((template) => (
|
||||||
<li key={template.id} className="group">
|
<li key={template.id} className="group">
|
||||||
<div
|
<div
|
||||||
draggable
|
draggable={canEdit}
|
||||||
onDragStart={(e) => handleDragStart(e, template)}
|
onDragStart={(e) => handleDragStart(e, template)}
|
||||||
className="flex cursor-grab items-center justify-between rounded border border-slate-200 bg-white px-2 py-1.5 text-xs shadow-sm active:cursor-grabbing"
|
className={`flex items-center justify-between rounded border border-slate-200 bg-white px-2 py-1.5 text-xs shadow-sm ${canEdit ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate font-medium text-slate-700">{template.name}</div>
|
<div className="truncate font-medium text-slate-700">{template.name}</div>
|
||||||
@@ -232,6 +236,7 @@ export default function DevicePalette() {
|
|||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleQuickAdd(template)}
|
onClick={() => handleQuickAdd(template)}
|
||||||
title="Add to canvas"
|
title="Add to canvas"
|
||||||
@@ -239,6 +244,7 @@ export default function DevicePalette() {
|
|||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { CollaboratorPermission } from '../../data/DiagramCollaboratorRepository'
|
||||||
|
import { UnknownUsernameError } from '../../data/DiagramCollaboratorRepository'
|
||||||
|
import { useDiagramCollaboratorStore } from '../../state/diagramCollaboratorStore'
|
||||||
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
|
import Modal from '../common/Modal'
|
||||||
|
|
||||||
|
export default function DiagramSharingModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const project = useProjectStore((s) => s.project)
|
||||||
|
const access = useProjectStore((s) => s.access)
|
||||||
|
const isOwner = access?.myPermission === 'owner'
|
||||||
|
const diagramId = project.id
|
||||||
|
|
||||||
|
const collaborators = useDiagramCollaboratorStore((s) => s.collaborators)
|
||||||
|
const isLoaded = useDiagramCollaboratorStore((s) => s.isLoaded)
|
||||||
|
const load = useDiagramCollaboratorStore((s) => s.load)
|
||||||
|
const add = useDiagramCollaboratorStore((s) => s.add)
|
||||||
|
const updatePermission = useDiagramCollaboratorStore((s) => s.updatePermission)
|
||||||
|
const remove = useDiagramCollaboratorStore((s) => s.remove)
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [permission, setPermission] = useState<CollaboratorPermission>('view')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(diagramId)
|
||||||
|
}, [diagramId, load])
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
const trimmed = username.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
setError(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await add(diagramId, trimmed, permission)
|
||||||
|
setUsername('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof UnknownUsernameError ? err.message : 'Could not add that person. Try again.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = (userId: string, name: string) => {
|
||||||
|
const confirmed = window.confirm(`Remove "${name}" from this diagram? They'll immediately lose access.`)
|
||||||
|
if (confirmed) remove(diagramId, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`Share "${project.name}"`} onClose={onClose} width="max-w-md">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isOwner && (
|
||||||
|
<div className="space-y-1.5 rounded border border-slate-200 bg-slate-50 p-2">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||||
|
placeholder="Username"
|
||||||
|
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={permission}
|
||||||
|
onChange={(e) => setPermission(e.target.value as CollaboratorPermission)}
|
||||||
|
className="rounded border border-slate-300 px-1 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<option value="view">Can view</option>
|
||||||
|
<option value="edit">Can edit</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={busy || !username.trim()}
|
||||||
|
className="shrink-0 rounded bg-indigo-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-[11px] text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{!isLoaded && <p className="py-2 text-center text-xs italic text-slate-400">Loading…</p>}
|
||||||
|
{isLoaded && collaborators.length === 0 && (
|
||||||
|
<p className="py-2 text-center text-xs italic text-slate-400">
|
||||||
|
Not shared with anyone yet{isOwner ? ' — add someone above.' : '.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{collaborators.map((c) => (
|
||||||
|
<div key={c.userId} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs">
|
||||||
|
<span className="font-medium text-slate-700">{c.username}</span>
|
||||||
|
{isOwner ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<select
|
||||||
|
value={c.permission}
|
||||||
|
onChange={(e) => updatePermission(diagramId, c.userId, e.target.value as CollaboratorPermission)}
|
||||||
|
className="rounded border border-slate-300 px-1 py-1 text-[11px]"
|
||||||
|
>
|
||||||
|
<option value="view">Can view</option>
|
||||||
|
<option value="edit">Can edit</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(c.userId, c.username)}
|
||||||
|
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="shrink-0 text-[11px] text-slate-400">{c.permission === 'edit' ? 'Can edit' : 'Can view'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
|
||||||
|
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useDiagramSnapshotStore } from '../../state/diagramSnapshotStore'
|
||||||
|
import { useProjectStore } from '../../state/projectStore'
|
||||||
|
import Modal from '../common/Modal'
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VersionHistoryModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const project = useProjectStore((s) => s.project)
|
||||||
|
const access = useProjectStore((s) => s.access)
|
||||||
|
const canRestore = access?.myPermission === 'owner' || access?.myPermission === 'edit'
|
||||||
|
const diagramId = project.id
|
||||||
|
|
||||||
|
const snapshots = useDiagramSnapshotStore((s) => s.snapshots)
|
||||||
|
const isLoaded = useDiagramSnapshotStore((s) => s.isLoaded)
|
||||||
|
const load = useDiagramSnapshotStore((s) => s.load)
|
||||||
|
const restore = useDiagramSnapshotStore((s) => s.restore)
|
||||||
|
const [restoringId, setRestoringId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(diagramId)
|
||||||
|
}, [diagramId, load])
|
||||||
|
|
||||||
|
const handleRestore = async (snapshotId: string, createdAt: string) => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Restore the diagram to how it was on ${formatDateTime(createdAt)}? If you've made changes since your last restore, the current state is saved as a new checkpoint first, so this can be undone.`,
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
setRestoringId(snapshotId)
|
||||||
|
try {
|
||||||
|
await restore(diagramId, snapshotId)
|
||||||
|
} finally {
|
||||||
|
setRestoringId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`Version History — "${project.name}"`} onClose={onClose} width="max-w-md">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-[11px] text-slate-500">
|
||||||
|
Checkpoints saved periodically while editing (up to the 50 most recent).
|
||||||
|
</p>
|
||||||
|
{!isLoaded && <p className="py-2 text-center text-xs italic text-slate-400">Loading…</p>}
|
||||||
|
{isLoaded && snapshots.length === 0 && (
|
||||||
|
<p className="py-2 text-center text-xs italic text-slate-400">No checkpoints yet.</p>
|
||||||
|
)}
|
||||||
|
{snapshots.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-slate-700">{formatDateTime(s.createdAt)}</div>
|
||||||
|
{s.savedByUsername && <div className="text-[10px] text-slate-400">by {s.savedByUsername}</div>}
|
||||||
|
</div>
|
||||||
|
{canRestore && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRestore(s.id, s.createdAt)}
|
||||||
|
disabled={restoringId === s.id}
|
||||||
|
className="shrink-0 rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-indigo-100 hover:text-indigo-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
|
||||||
|
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type CollaboratorPermission = 'view' | 'edit'
|
||||||
|
|
||||||
|
export interface DiagramCollaborator {
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
permission: CollaboratorPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown by `add` when the given username doesn't resolve to any account —
|
||||||
|
* a normal, expected outcome (typo, or the person hasn't signed up yet),
|
||||||
|
* not a system failure. */
|
||||||
|
export class UnknownUsernameError extends Error {}
|
||||||
|
|
||||||
|
/** Storage abstraction for a diagram's collaborator list, per
|
||||||
|
* organized-ideas.md §8: the owner shares with specific people by
|
||||||
|
* username, choosing view or edit access per person. RLS restricts
|
||||||
|
* add/update/remove to the diagram's owner (or a Super Admin) — this
|
||||||
|
* interface doesn't re-check that, it just reflects what the backend allows. */
|
||||||
|
export interface DiagramCollaboratorRepository {
|
||||||
|
list(diagramId: string): Promise<DiagramCollaborator[]>
|
||||||
|
/** Resolves `username` to an account and adds them with `permission`.
|
||||||
|
* Throws UnknownUsernameError if no such username exists. */
|
||||||
|
add(diagramId: string, username: string, permission: CollaboratorPermission): Promise<DiagramCollaborator>
|
||||||
|
updatePermission(diagramId: string, userId: string, permission: CollaboratorPermission): Promise<void>
|
||||||
|
remove(diagramId: string, userId: string): Promise<void>
|
||||||
|
}
|
||||||
@@ -19,6 +19,16 @@ export interface DiagramSummary {
|
|||||||
* user-facing copy still say "Project" — that's a separate, larger mechanical rename
|
* user-facing copy still say "Project" — that's a separate, larger mechanical rename
|
||||||
* across the whole app, tracked as its own task rather than bundled into this one.
|
* across the whole app, tracked as its own task rather than bundled into this one.
|
||||||
*/
|
*/
|
||||||
|
/** Your relationship to a diagram — who owns it, and what you personally
|
||||||
|
* can do with it (organized-ideas.md §8's per-collaborator view/edit
|
||||||
|
* permissions). Drives the frontend's own view-only lockdown, since RLS
|
||||||
|
* silently no-ops a blocked write rather than erroring — the UI needs to
|
||||||
|
* know *before* the user tries, not just fail quietly after. */
|
||||||
|
export interface DiagramAccess {
|
||||||
|
ownerId: string
|
||||||
|
myPermission: 'owner' | 'edit' | 'view'
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiagramRepository {
|
export interface DiagramRepository {
|
||||||
/** Every diagram visible to the current user, most recently updated first. */
|
/** Every diagram visible to the current user, most recently updated first. */
|
||||||
list(): Promise<DiagramSummary[]>
|
list(): Promise<DiagramSummary[]>
|
||||||
@@ -28,4 +38,6 @@ export interface DiagramRepository {
|
|||||||
save(project: Project): Promise<void>
|
save(project: Project): Promise<void>
|
||||||
/** Permanently delete one diagram by id. */
|
/** Permanently delete one diagram by id. */
|
||||||
deleteById(id: string): Promise<void>
|
deleteById(id: string): Promise<void>
|
||||||
|
/** Who owns this diagram and what the current user can do with it. */
|
||||||
|
getAccess(id: string): Promise<DiagramAccess | null>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Project } from '../domain/types'
|
||||||
|
|
||||||
|
export interface DiagramSnapshotSummary {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
savedByUsername?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Storage abstraction for a diagram's rolling snapshot history —
|
||||||
|
* organized-ideas.md §8's undo/recovery safety net, now that turn-based
|
||||||
|
* collaborators can edit a diagram too. Writing snapshots happens as a
|
||||||
|
* side effect of normal saves (see SupabaseDiagramRepository.save), not
|
||||||
|
* through this interface — this is purely the read/restore side. */
|
||||||
|
export interface DiagramSnapshotRepository {
|
||||||
|
/** Most recent first. */
|
||||||
|
list(diagramId: string): Promise<DiagramSnapshotSummary[]>
|
||||||
|
/** Restores a snapshot as the diagram's current data. When
|
||||||
|
* `writeSafetySnapshot` is true, the pre-restore state is snapshotted
|
||||||
|
* first so restoring is itself undoable — the caller passes false when
|
||||||
|
* it already knows the live state is an unmodified copy of a snapshot
|
||||||
|
* that still exists (e.g. restoring again right after an earlier
|
||||||
|
* restore, with no edits in between), so a second copy of it would just
|
||||||
|
* be a duplicate. Returns the restored Project so the caller can update
|
||||||
|
* the open diagram in place. */
|
||||||
|
restore(diagramId: string, snapshotId: string, writeSafetySnapshot: boolean): Promise<Project | null>
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Project } from '../domain/types'
|
import type { Project } from '../domain/types'
|
||||||
import type { DiagramRepository, DiagramSummary } from './DiagramRepository'
|
import type { DiagramAccess, DiagramRepository, DiagramSummary } from './DiagramRepository'
|
||||||
import { normalizeProject } from './exportImport'
|
import { normalizeProject } from './exportImport'
|
||||||
|
|
||||||
const STORAGE_KEY = 'av-planner:diagrams:v1'
|
const STORAGE_KEY = 'av-planner:diagrams:v1'
|
||||||
@@ -48,4 +48,11 @@ export class LocalStorageDiagramRepository implements DiagramRepository {
|
|||||||
delete all[id]
|
delete all[id]
|
||||||
writeAll(all)
|
writeAll(all)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** localStorage is inherently single-user — always the owner, never a
|
||||||
|
* shared collaborator. */
|
||||||
|
async getAccess(id: string): Promise<DiagramAccess | null> {
|
||||||
|
const diagram = readAll()[id]
|
||||||
|
return diagram ? { ownerId: 'local', myPermission: 'owner' } : null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type {
|
||||||
|
CollaboratorPermission,
|
||||||
|
DiagramCollaborator,
|
||||||
|
DiagramCollaboratorRepository,
|
||||||
|
} from './DiagramCollaboratorRepository'
|
||||||
|
import { UnknownUsernameError } from './DiagramCollaboratorRepository'
|
||||||
|
import { supabase } from './supabaseClient'
|
||||||
|
|
||||||
|
interface CollaboratorRow {
|
||||||
|
user_id: string
|
||||||
|
permission: CollaboratorPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SupabaseDiagramCollaboratorRepository implements DiagramCollaboratorRepository {
|
||||||
|
async list(diagramId: string): Promise<DiagramCollaborator[]> {
|
||||||
|
const [rowsResult, usernamesResult] = await Promise.all([
|
||||||
|
supabase.from('diagram_collaborators').select('user_id, permission').eq('diagram_id', diagramId),
|
||||||
|
supabase.rpc('diagram_collaborator_usernames', { p_diagram_id: diagramId }),
|
||||||
|
])
|
||||||
|
if (rowsResult.error) {
|
||||||
|
console.error('Failed to load collaborators from Supabase', rowsResult.error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (usernamesResult.error) {
|
||||||
|
console.error('Failed to resolve collaborator usernames from Supabase', usernamesResult.error)
|
||||||
|
}
|
||||||
|
const usernameById = new Map<string, string>(
|
||||||
|
((usernamesResult.data ?? []) as { user_id: string; username: string }[]).map((r) => [r.user_id, r.username]),
|
||||||
|
)
|
||||||
|
return ((rowsResult.data ?? []) as CollaboratorRow[]).map((row) => ({
|
||||||
|
userId: row.user_id,
|
||||||
|
username: usernameById.get(row.user_id) ?? row.user_id,
|
||||||
|
permission: row.permission,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(diagramId: string, username: string, permission: CollaboratorPermission): Promise<DiagramCollaborator> {
|
||||||
|
const { data: userId, error: lookupError } = await supabase.rpc('find_user_id_by_username', { p_username: username })
|
||||||
|
if (lookupError) {
|
||||||
|
console.error('Failed to resolve username to an id', lookupError)
|
||||||
|
throw lookupError
|
||||||
|
}
|
||||||
|
if (!userId) {
|
||||||
|
throw new UnknownUsernameError(`No account found for username "${username}".`)
|
||||||
|
}
|
||||||
|
const { error } = await supabase.from('diagram_collaborators').insert({ diagram_id: diagramId, user_id: userId, permission })
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to add collaborator in Supabase', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return { userId, username, permission }
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePermission(diagramId: string, userId: string, permission: CollaboratorPermission): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('diagram_collaborators')
|
||||||
|
.update({ permission })
|
||||||
|
.eq('diagram_id', diagramId)
|
||||||
|
.eq('user_id', userId)
|
||||||
|
if (error) console.error('Failed to update collaborator permission in Supabase', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(diagramId: string, userId: string): Promise<void> {
|
||||||
|
const { error } = await supabase.from('diagram_collaborators').delete().eq('diagram_id', diagramId).eq('user_id', userId)
|
||||||
|
if (error) console.error('Failed to remove collaborator in Supabase', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Project } from '../domain/types'
|
import type { Project } from '../domain/types'
|
||||||
import type { DiagramRepository, DiagramSummary } from './DiagramRepository'
|
import type { DiagramAccess, DiagramRepository, DiagramSummary } from './DiagramRepository'
|
||||||
import { normalizeProject } from './exportImport'
|
import { normalizeProject } from './exportImport'
|
||||||
import { supabase } from './supabaseClient'
|
import { supabase } from './supabaseClient'
|
||||||
|
|
||||||
@@ -49,10 +49,13 @@ export class SupabaseDiagramRepository implements DiagramRepository {
|
|||||||
|
|
||||||
// Deliberately update-or-insert rather than a blind upsert: an upsert
|
// Deliberately update-or-insert rather than a blind upsert: an upsert
|
||||||
// would resend owner_id on every save and silently reassign ownership
|
// would resend owner_id on every save and silently reassign ownership
|
||||||
// to whoever saves last. Not reachable yet (no collaborator UI exists),
|
// to whoever saves last — once diagram sharing (§8) is in play, an
|
||||||
// but once diagram sharing (§8) lands, an edit-collaborator saving
|
// edit-collaborator saving changes must never be able to overwrite who
|
||||||
// changes must never be able to overwrite who owns the diagram.
|
// owns the diagram.
|
||||||
const { data: existing } = await supabase.from('diagrams').select('id').eq('id', project.id).maybeSingle()
|
const { data: existing } = await supabase.from('diagrams').select('id, data').eq('id', project.id).maybeSingle()
|
||||||
|
if (existing) {
|
||||||
|
await this.maybeWriteSnapshot(project.id, existing.data as Project, user.id)
|
||||||
|
}
|
||||||
const payload = { name: project.name, data: project, updated_at: new Date().toISOString() }
|
const payload = { name: project.name, data: project, updated_at: new Date().toISOString() }
|
||||||
|
|
||||||
const { error } = existing
|
const { error } = existing
|
||||||
@@ -64,10 +67,62 @@ export class SupabaseDiagramRepository implements DiagramRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checkpoints the *pre-save* state periodically (§8's undo/recovery net)
|
||||||
|
// rather than on every autosave — autosave fires 400ms after each edit,
|
||||||
|
// which would otherwise flood the snapshot table with one row per
|
||||||
|
// keystroke-adjacent change instead of meaningful recovery points.
|
||||||
|
private static readonly SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
private async maybeWriteSnapshot(diagramId: string, previousData: Project, userId: string): Promise<void> {
|
||||||
|
const { data: lastSnapshot } = await supabase
|
||||||
|
.from('diagram_snapshots')
|
||||||
|
.select('created_at')
|
||||||
|
.eq('diagram_id', diagramId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
const dueForSnapshot =
|
||||||
|
!lastSnapshot || Date.now() - new Date(lastSnapshot.created_at).getTime() >= SupabaseDiagramRepository.SNAPSHOT_INTERVAL_MS
|
||||||
|
if (!dueForSnapshot) return
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('diagram_snapshots')
|
||||||
|
.insert({ diagram_id: diagramId, data: previousData, saved_by: userId })
|
||||||
|
if (error) console.error('Failed to write diagram snapshot', error)
|
||||||
|
}
|
||||||
|
|
||||||
async deleteById(id: string): Promise<void> {
|
async deleteById(id: string): Promise<void> {
|
||||||
const { error } = await supabase.from('diagrams').delete().eq('id', id)
|
const { error } = await supabase.from('diagrams').delete().eq('id', id)
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Failed to delete diagram from Supabase', error)
|
console.error('Failed to delete diagram from Supabase', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAccess(id: string): Promise<DiagramAccess | null> {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser()
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
const { data: diagram, error } = await supabase.from('diagrams').select('owner_id').eq('id', id).maybeSingle()
|
||||||
|
if (error) console.error('Failed to load diagram access info from Supabase', error)
|
||||||
|
if (!diagram) return null
|
||||||
|
|
||||||
|
if (diagram.owner_id === user.id) return { ownerId: diagram.owner_id, myPermission: 'owner' }
|
||||||
|
|
||||||
|
const { data: collaborator } = await supabase
|
||||||
|
.from('diagram_collaborators')
|
||||||
|
.select('permission')
|
||||||
|
.eq('diagram_id', id)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.maybeSingle()
|
||||||
|
if (collaborator) return { ownerId: diagram.owner_id, myPermission: collaborator.permission }
|
||||||
|
|
||||||
|
// Visible without being the owner or a listed collaborator only happens
|
||||||
|
// for a Super Admin (diagrams_select's override) — diagrams_update
|
||||||
|
// grants them unconditional edit rights too, so 'edit' is the accurate
|
||||||
|
// capability to report, not a misleading read-only default.
|
||||||
|
return { ownerId: diagram.owner_id, myPermission: 'edit' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Project } from '../domain/types'
|
||||||
|
import type { DiagramSnapshotRepository, DiagramSnapshotSummary } from './DiagramSnapshotRepository'
|
||||||
|
import { normalizeProject } from './exportImport'
|
||||||
|
import { supabase } from './supabaseClient'
|
||||||
|
|
||||||
|
interface SnapshotRow {
|
||||||
|
id: string
|
||||||
|
created_at: string
|
||||||
|
saved_by: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SupabaseDiagramSnapshotRepository implements DiagramSnapshotRepository {
|
||||||
|
async list(diagramId: string): Promise<DiagramSnapshotSummary[]> {
|
||||||
|
const [rowsResult, usernamesResult] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from('diagram_snapshots')
|
||||||
|
.select('id, created_at, saved_by')
|
||||||
|
.eq('diagram_id', diagramId)
|
||||||
|
.order('created_at', { ascending: false }),
|
||||||
|
supabase.rpc('diagram_snapshot_saved_by_usernames', { p_diagram_id: diagramId }),
|
||||||
|
])
|
||||||
|
if (rowsResult.error) {
|
||||||
|
console.error('Failed to load diagram snapshots from Supabase', rowsResult.error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (usernamesResult.error) {
|
||||||
|
console.error('Failed to resolve snapshot saver usernames from Supabase', usernamesResult.error)
|
||||||
|
}
|
||||||
|
const usernameById = new Map<string, string>(
|
||||||
|
((usernamesResult.data ?? []) as { user_id: string; username: string }[]).map((r) => [r.user_id, r.username]),
|
||||||
|
)
|
||||||
|
return ((rowsResult.data ?? []) as SnapshotRow[]).map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
savedByUsername: row.saved_by ? usernameById.get(row.saved_by) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(diagramId: string, snapshotId: string, writeSafetySnapshot: boolean): Promise<Project | null> {
|
||||||
|
const { data: snapshot, error } = await supabase.from('diagram_snapshots').select('data').eq('id', snapshotId).maybeSingle()
|
||||||
|
if (error) console.error('Failed to load snapshot from Supabase', error)
|
||||||
|
if (!snapshot) return null
|
||||||
|
|
||||||
|
// Snapshot the pre-restore state first, so restoring is itself
|
||||||
|
// recoverable — the same reasoning as any other checkpoint. Skipped
|
||||||
|
// when the caller already knows the live state is an unmodified copy
|
||||||
|
// of an existing snapshot (see DiagramSnapshotRepository's doc
|
||||||
|
// comment) — writing it again would just be a duplicate.
|
||||||
|
if (writeSafetySnapshot) {
|
||||||
|
const [{ data: current }, { data: userData }] = await Promise.all([
|
||||||
|
supabase.from('diagrams').select('data').eq('id', diagramId).maybeSingle(),
|
||||||
|
supabase.auth.getUser(),
|
||||||
|
])
|
||||||
|
if (current) {
|
||||||
|
const { error: snapshotError } = await supabase
|
||||||
|
.from('diagram_snapshots')
|
||||||
|
.insert({ diagram_id: diagramId, data: current.data, saved_by: userData.user?.id ?? null })
|
||||||
|
if (snapshotError) console.error('Failed to snapshot pre-restore state', snapshotError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restored = normalizeProject(snapshot.data as Project)
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('diagrams')
|
||||||
|
.update({ data: restored, name: restored.name, updated_at: new Date().toISOString() })
|
||||||
|
.eq('id', diagramId)
|
||||||
|
if (updateError) {
|
||||||
|
console.error('Failed to restore snapshot in Supabase', updateError)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return restored
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type {
|
||||||
|
CollaboratorPermission,
|
||||||
|
DiagramCollaborator,
|
||||||
|
DiagramCollaboratorRepository,
|
||||||
|
} from '../data/DiagramCollaboratorRepository'
|
||||||
|
import { SupabaseDiagramCollaboratorRepository } from '../data/SupabaseDiagramCollaboratorRepository'
|
||||||
|
|
||||||
|
const repository: DiagramCollaboratorRepository = new SupabaseDiagramCollaboratorRepository()
|
||||||
|
|
||||||
|
interface DiagramCollaboratorStoreState {
|
||||||
|
collaborators: DiagramCollaborator[]
|
||||||
|
isLoaded: boolean
|
||||||
|
|
||||||
|
load: (diagramId: string) => Promise<void>
|
||||||
|
add: (diagramId: string, username: string, permission: CollaboratorPermission) => Promise<void>
|
||||||
|
updatePermission: (diagramId: string, userId: string, permission: CollaboratorPermission) => Promise<void>
|
||||||
|
remove: (diagramId: string, userId: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDiagramCollaboratorStore = create<DiagramCollaboratorStoreState>((set) => ({
|
||||||
|
collaborators: [],
|
||||||
|
isLoaded: false,
|
||||||
|
|
||||||
|
load: async (diagramId) => {
|
||||||
|
const collaborators = await repository.list(diagramId)
|
||||||
|
set({ collaborators, isLoaded: true })
|
||||||
|
},
|
||||||
|
|
||||||
|
add: async (diagramId, username, permission) => {
|
||||||
|
const created = await repository.add(diagramId, username, permission)
|
||||||
|
set((state) => ({ collaborators: [...state.collaborators, created] }))
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePermission: async (diagramId, userId, permission) => {
|
||||||
|
await repository.updatePermission(diagramId, userId, permission)
|
||||||
|
set((state) => ({
|
||||||
|
collaborators: state.collaborators.map((c) => (c.userId === userId ? { ...c, permission } : c)),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (diagramId, userId) => {
|
||||||
|
await repository.remove(diagramId, userId)
|
||||||
|
set((state) => ({ collaborators: state.collaborators.filter((c) => c.userId !== userId) }))
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { DiagramSnapshotRepository, DiagramSnapshotSummary } from '../data/DiagramSnapshotRepository'
|
||||||
|
import { SupabaseDiagramSnapshotRepository } from '../data/SupabaseDiagramSnapshotRepository'
|
||||||
|
import type { Project } from '../domain/types'
|
||||||
|
import { useProjectStore } from './projectStore'
|
||||||
|
|
||||||
|
const repository: DiagramSnapshotRepository = new SupabaseDiagramSnapshotRepository()
|
||||||
|
|
||||||
|
interface DiagramSnapshotStoreState {
|
||||||
|
snapshots: DiagramSnapshotSummary[]
|
||||||
|
isLoaded: boolean
|
||||||
|
|
||||||
|
load: (diagramId: string) => Promise<void>
|
||||||
|
/** Restores a snapshot and applies it to the open diagram (via
|
||||||
|
* projectStore.applyRestoredProject) in one step. Skips writing a
|
||||||
|
* pointless duplicate safety snapshot when the live diagram is already
|
||||||
|
* an unmodified copy of a snapshot still sitting in the list — see
|
||||||
|
* projectStore's restoredFromSnapshotId/restoredProjectUpdatedAt.
|
||||||
|
* Returns the restored Project, or null if the restore failed. */
|
||||||
|
restore: (diagramId: string, snapshotId: string) => Promise<Project | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDiagramSnapshotStore = create<DiagramSnapshotStoreState>((set) => ({
|
||||||
|
snapshots: [],
|
||||||
|
isLoaded: false,
|
||||||
|
|
||||||
|
load: async (diagramId) => {
|
||||||
|
const snapshots = await repository.list(diagramId)
|
||||||
|
set({ snapshots, isLoaded: true })
|
||||||
|
},
|
||||||
|
|
||||||
|
restore: async (diagramId, snapshotId) => {
|
||||||
|
const { project, restoredFromSnapshotId, restoredProjectUpdatedAt } = useProjectStore.getState()
|
||||||
|
const knownUnmodifiedSinceLastRestore =
|
||||||
|
restoredFromSnapshotId !== null && project.updatedAt === restoredProjectUpdatedAt
|
||||||
|
|
||||||
|
const restored = await repository.restore(diagramId, snapshotId, !knownUnmodifiedSinceLastRestore)
|
||||||
|
if (restored) {
|
||||||
|
useProjectStore.getState().applyRestoredProject(restored, snapshotId)
|
||||||
|
const snapshots = await repository.list(diagramId)
|
||||||
|
set({ snapshots })
|
||||||
|
}
|
||||||
|
return restored
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -3,7 +3,7 @@ import { create } from 'zustand'
|
|||||||
import { validateConnection } from '../domain/compatibility'
|
import { validateConnection } from '../domain/compatibility'
|
||||||
import { allDeviceTemplates, createDeviceFromTemplate, createEmptyProject, incrementPortName } from '../domain/project'
|
import { allDeviceTemplates, createDeviceFromTemplate, createEmptyProject, incrementPortName } from '../domain/project'
|
||||||
import type { Catalog, Connection, Device, DeviceCategory, Port, Project } from '../domain/types'
|
import type { Catalog, Connection, Device, DeviceCategory, Port, Project } from '../domain/types'
|
||||||
import type { DiagramRepository, DiagramSummary } from '../data/DiagramRepository'
|
import type { DiagramAccess, DiagramRepository, DiagramSummary } from '../data/DiagramRepository'
|
||||||
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
|
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
|
||||||
|
|
||||||
// This is exactly the swap the repository pattern was built for: only this
|
// This is exactly the swap the repository pattern was built for: only this
|
||||||
@@ -48,6 +48,26 @@ interface ProjectStoreState {
|
|||||||
* change, just refreshed at the moments that matter (initial load, and
|
* change, just refreshed at the moments that matter (initial load, and
|
||||||
* whenever the diagram manager UI opens or changes something). */
|
* whenever the diagram manager UI opens or changes something). */
|
||||||
diagramList: DiagramSummary[]
|
diagramList: DiagramSummary[]
|
||||||
|
/** Who owns the *currently open* diagram and what the current user can do
|
||||||
|
* with it — refreshed whenever the open diagram changes, not kept live
|
||||||
|
* (matches §8's turn-based, not realtime, collaboration model: a
|
||||||
|
* permission change made by the owner while you're viewing takes effect
|
||||||
|
* next time you load the diagram, not mid-session). Null only during the
|
||||||
|
* brief window before the very first diagram finishes loading. */
|
||||||
|
access: DiagramAccess | null
|
||||||
|
/** Set to a snapshot's id right after restoring it, meaning the live
|
||||||
|
* diagram data is verbatim identical to that already-existing snapshot
|
||||||
|
* row — see applyRestoredProject. `restoredProjectUpdatedAt` is the
|
||||||
|
* project's own `updatedAt` at that moment; touch() always advances
|
||||||
|
* `updatedAt` on a genuine edit, so comparing the two later is a free
|
||||||
|
* way to tell "has anything changed since this restore" without
|
||||||
|
* hooking every mutating action. Both null once anything invalidates
|
||||||
|
* the match (an edit, or loading/switching to a diagram fresh). Used by
|
||||||
|
* DiagramSnapshotStore to skip writing a pointless duplicate safety
|
||||||
|
* snapshot when the user restores several old versions in a row without
|
||||||
|
* editing in between. */
|
||||||
|
restoredFromSnapshotId: string | null
|
||||||
|
restoredProjectUpdatedAt: string | null
|
||||||
selectedDeviceId: string | null
|
selectedDeviceId: string | null
|
||||||
selectedConnectionId: string | null
|
selectedConnectionId: string | null
|
||||||
lastConnectionError: string | null
|
lastConnectionError: string | null
|
||||||
@@ -65,6 +85,11 @@ interface ProjectStoreState {
|
|||||||
* rather than risking a collision with (or silently overwriting) an
|
* rather than risking a collision with (or silently overwriting) an
|
||||||
* existing one that happens to share the file's id. */
|
* existing one that happens to share the file's id. */
|
||||||
importProject: (project: Project) => Promise<void>
|
importProject: (project: Project) => Promise<void>
|
||||||
|
/** Applies a diagram that was already persisted by something else (the
|
||||||
|
* version-history "Restore" action already wrote the restored data to
|
||||||
|
* Supabase) — just reflects it in the currently open canvas, without
|
||||||
|
* re-triggering an import-style "treat this as a brand new diagram". */
|
||||||
|
applyRestoredProject: (project: Project, snapshotId: string) => void
|
||||||
/** Switches the active diagram to another one of the user's own. */
|
/** Switches the active diagram to another one of the user's own. */
|
||||||
switchToDiagram: (id: string) => Promise<void>
|
switchToDiagram: (id: string) => Promise<void>
|
||||||
/** Permanently deletes a diagram. If it's the currently open one, falls
|
/** Permanently deletes a diagram. If it's the currently open one, falls
|
||||||
@@ -115,6 +140,9 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
|
|||||||
project: createEmptyProject(),
|
project: createEmptyProject(),
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
diagramList: [],
|
diagramList: [],
|
||||||
|
access: null,
|
||||||
|
restoredFromSnapshotId: null,
|
||||||
|
restoredProjectUpdatedAt: null,
|
||||||
selectedDeviceId: null,
|
selectedDeviceId: null,
|
||||||
selectedConnectionId: null,
|
selectedConnectionId: null,
|
||||||
lastConnectionError: null,
|
lastConnectionError: null,
|
||||||
@@ -125,7 +153,8 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
|
|||||||
const loaded = lastId ? await repository.loadById(lastId) : null
|
const loaded = lastId ? await repository.loadById(lastId) : null
|
||||||
const project = loaded ?? (await loadMostRecent()) ?? createEmptyProject()
|
const project = loaded ?? (await loadMostRecent()) ?? createEmptyProject()
|
||||||
setLastDiagramId(project.id)
|
setLastDiagramId(project.id)
|
||||||
set({ project, isLoaded: true })
|
const access = await repository.getAccess(project.id)
|
||||||
|
set({ project, access, isLoaded: true, restoredFromSnapshotId: null, restoredProjectUpdatedAt: null })
|
||||||
get().refreshDiagramList()
|
get().refreshDiagramList()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -137,8 +166,16 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
|
|||||||
newProject: async (name = 'Untitled Project') => {
|
newProject: async (name = 'Untitled Project') => {
|
||||||
const project = createEmptyProject(name)
|
const project = createEmptyProject(name)
|
||||||
setLastDiagramId(project.id)
|
setLastDiagramId(project.id)
|
||||||
set({ project, selectedDeviceId: null, selectedConnectionId: null })
|
set({
|
||||||
|
project,
|
||||||
|
selectedDeviceId: null,
|
||||||
|
selectedConnectionId: null,
|
||||||
|
restoredFromSnapshotId: null,
|
||||||
|
restoredProjectUpdatedAt: null,
|
||||||
|
})
|
||||||
await repository.save(project)
|
await repository.save(project)
|
||||||
|
const access = await repository.getAccess(project.id)
|
||||||
|
set({ access })
|
||||||
get().refreshDiagramList()
|
get().refreshDiagramList()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -146,11 +183,29 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
|
|||||||
set((state) => ({ project: touch({ ...state.project, name }) }))
|
set((state) => ({ project: touch({ ...state.project, name }) }))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
applyRestoredProject: (project, snapshotId) => {
|
||||||
|
set({
|
||||||
|
project,
|
||||||
|
selectedDeviceId: null,
|
||||||
|
selectedConnectionId: null,
|
||||||
|
restoredFromSnapshotId: snapshotId,
|
||||||
|
restoredProjectUpdatedAt: project.updatedAt,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
importProject: async (project) => {
|
importProject: async (project) => {
|
||||||
const fresh = touch({ ...project, id: uuid() })
|
const fresh = touch({ ...project, id: uuid() })
|
||||||
setLastDiagramId(fresh.id)
|
setLastDiagramId(fresh.id)
|
||||||
set({ project: fresh, selectedDeviceId: null, selectedConnectionId: null })
|
set({
|
||||||
|
project: fresh,
|
||||||
|
selectedDeviceId: null,
|
||||||
|
selectedConnectionId: null,
|
||||||
|
restoredFromSnapshotId: null,
|
||||||
|
restoredProjectUpdatedAt: null,
|
||||||
|
})
|
||||||
await repository.save(fresh)
|
await repository.save(fresh)
|
||||||
|
const access = await repository.getAccess(fresh.id)
|
||||||
|
set({ access })
|
||||||
get().refreshDiagramList()
|
get().refreshDiagramList()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -159,7 +214,15 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
|
|||||||
const loaded = await repository.loadById(id)
|
const loaded = await repository.loadById(id)
|
||||||
if (!loaded) return
|
if (!loaded) return
|
||||||
setLastDiagramId(loaded.id)
|
setLastDiagramId(loaded.id)
|
||||||
set({ project: loaded, selectedDeviceId: null, selectedConnectionId: null })
|
const access = await repository.getAccess(loaded.id)
|
||||||
|
set({
|
||||||
|
project: loaded,
|
||||||
|
access,
|
||||||
|
selectedDeviceId: null,
|
||||||
|
selectedConnectionId: null,
|
||||||
|
restoredFromSnapshotId: null,
|
||||||
|
restoredProjectUpdatedAt: null,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteDiagram: async (id) => {
|
deleteDiagram: async (id) => {
|
||||||
@@ -167,7 +230,15 @@ export const useProjectStore = create<ProjectStoreState>((set, get) => ({
|
|||||||
if (get().project.id === id) {
|
if (get().project.id === id) {
|
||||||
const next = (await loadMostRecent()) ?? createEmptyProject()
|
const next = (await loadMostRecent()) ?? createEmptyProject()
|
||||||
setLastDiagramId(next.id)
|
setLastDiagramId(next.id)
|
||||||
set({ project: next, selectedDeviceId: null, selectedConnectionId: null })
|
const access = await repository.getAccess(next.id)
|
||||||
|
set({
|
||||||
|
project: next,
|
||||||
|
access,
|
||||||
|
selectedDeviceId: null,
|
||||||
|
selectedConnectionId: null,
|
||||||
|
restoredFromSnapshotId: null,
|
||||||
|
restoredProjectUpdatedAt: null,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
get().refreshDiagramList()
|
get().refreshDiagramList()
|
||||||
},
|
},
|
||||||
@@ -331,6 +402,10 @@ useProjectStore.subscribe((state, prevState) => {
|
|||||||
// load, not a user edit) and any tick where `project` didn't change.
|
// load, not a user edit) and any tick where `project` didn't change.
|
||||||
if (!prevState.isLoaded) return
|
if (!prevState.isLoaded) return
|
||||||
if (state.project === prevState.project) return
|
if (state.project === prevState.project) return
|
||||||
|
// Defense in depth: the UI shouldn't expose any mutating control to a
|
||||||
|
// view-only collaborator in the first place (see FlowCanvas/inspectors),
|
||||||
|
// but skip the write here too rather than relying on RLS's silent no-op.
|
||||||
|
if (state.access?.myPermission === 'view') return
|
||||||
if (saveTimeout) clearTimeout(saveTimeout)
|
if (saveTimeout) clearTimeout(saveTimeout)
|
||||||
saveTimeout = setTimeout(() => {
|
saveTimeout = setTimeout(() => {
|
||||||
repository.save(state.project).catch((err) => console.error('Autosave failed', err))
|
repository.save(state.project).catch((err) => console.error('Autosave failed', err))
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
-- Diagram sharing/collaborators UI support, per organized-ideas.md §8. The
|
||||||
|
-- diagrams/diagram_collaborators/diagram_snapshots tables and their RLS
|
||||||
|
-- already exist (init schema) — this migration adds the two small pieces
|
||||||
|
-- of backend those needed to actually be usable from a UI.
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Username -> id lookup, for "share with @username". profiles_select
|
||||||
|
-- deliberately keeps a regular user from browsing other users' profiles
|
||||||
|
-- (organized-ideas.md §2/§6), but a username is meant to be a shareable
|
||||||
|
-- handle — that's the whole point of having one — so resolving it to an id
|
||||||
|
-- (and nothing else: no email, no role) is safe to expose broadly, unlike
|
||||||
|
-- the Admin-gated lookups elsewhere in this schema. Any authenticated user
|
||||||
|
-- can call this; there's no privilege check because none is needed.
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.find_user_id_by_username(p_username text)
|
||||||
|
returns uuid
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select id from public.profiles where username = p_username;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Collaborator usernames for one diagram's sharing UI. Same problem as
|
||||||
|
-- above in reverse: diagram_collaborators only stores user ids, and
|
||||||
|
-- profiles_select_self_or_super_admin blocks a regular owner from reading
|
||||||
|
-- some *other* user's profile row directly to get their username. Gated
|
||||||
|
-- to "can you see this diagram at all" — the same condition as
|
||||||
|
-- diagrams_select's USING clause, reusing its own helper functions so the
|
||||||
|
-- two can't drift apart.
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.diagram_collaborator_usernames(p_diagram_id uuid)
|
||||||
|
returns table(user_id uuid, username text)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not (
|
||||||
|
public.is_super_admin()
|
||||||
|
or public.diagram_owner_id(p_diagram_id) = auth.uid()
|
||||||
|
or public.diagram_collaborator_permission(p_diagram_id, auth.uid()) is not null
|
||||||
|
) then
|
||||||
|
raise exception 'insufficient_privilege' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return query
|
||||||
|
select dc.user_id, p.username
|
||||||
|
from public.diagram_collaborators dc
|
||||||
|
join public.profiles p on p.id = dc.user_id
|
||||||
|
where dc.diagram_id = p_diagram_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Rolling snapshot retention (organized-ideas.md §8: "keep a rolling
|
||||||
|
-- window of recent diagram snapshots... exact policy TBD" — settled on
|
||||||
|
-- count-based, 50 per diagram). Enforced at write time via a trigger
|
||||||
|
-- rather than a scheduled job: diagram_snapshots has no update/delete
|
||||||
|
-- policy for regular users at all (it's meant to be immutable from their
|
||||||
|
-- side), so pruning has to run as this SECURITY DEFINER function
|
||||||
|
-- regardless of whether it's trigger- or cron-driven — a trigger is
|
||||||
|
-- simpler than also standing up pg_cron for this app's scale.
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.prune_diagram_snapshots()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
delete from public.diagram_snapshots
|
||||||
|
where diagram_id = new.diagram_id
|
||||||
|
and id not in (
|
||||||
|
select id from public.diagram_snapshots
|
||||||
|
where diagram_id = new.diagram_id
|
||||||
|
order by created_at desc
|
||||||
|
limit 50
|
||||||
|
);
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create trigger trg_prune_diagram_snapshots
|
||||||
|
after insert on public.diagram_snapshots
|
||||||
|
for each row
|
||||||
|
execute function public.prune_diagram_snapshots();
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Who saved each snapshot, for the version-history UI. Same shape as
|
||||||
|
-- diagram_collaborator_usernames above (gated to "can you see this
|
||||||
|
-- diagram", batched per diagram rather than per snapshot).
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.diagram_snapshot_saved_by_usernames(p_diagram_id uuid)
|
||||||
|
returns table(user_id uuid, username text)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not (
|
||||||
|
public.is_super_admin()
|
||||||
|
or public.diagram_owner_id(p_diagram_id) = auth.uid()
|
||||||
|
or public.diagram_collaborator_permission(p_diagram_id, auth.uid()) is not null
|
||||||
|
) then
|
||||||
|
raise exception 'insufficient_privilege' using errcode = '42501';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return query
|
||||||
|
select distinct p.id, p.username
|
||||||
|
from public.diagram_snapshots s
|
||||||
|
join public.profiles p on p.id = s.saved_by
|
||||||
|
where s.diagram_id = p_diagram_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
+81
-1
@@ -25,7 +25,7 @@ begin;
|
|||||||
|
|
||||||
create extension if not exists pgtap with schema extensions;
|
create extension if not exists pgtap with schema extensions;
|
||||||
|
|
||||||
select plan(43);
|
select plan(52);
|
||||||
|
|
||||||
-- ----------------------------------------------------------------------
|
-- ----------------------------------------------------------------------
|
||||||
-- Fixtures (as postgres — RLS does not apply)
|
-- Fixtures (as postgres — RLS does not apply)
|
||||||
@@ -177,8 +177,20 @@ select lives_ok(
|
|||||||
'bob (edit collaborator) can now update alice''s diagram'
|
'bob (edit collaborator) can now update alice''s diagram'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select username from public.diagram_collaborator_usernames('b0000000-0000-0000-0000-000000000001') where user_id = '22222222-2222-2222-2222-222222222222'),
|
||||||
|
'bob',
|
||||||
|
'bob (a collaborator) can resolve the diagram''s collaborator usernames'
|
||||||
|
);
|
||||||
|
|
||||||
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
||||||
|
|
||||||
|
select throws_ok(
|
||||||
|
$$ select * from public.diagram_collaborator_usernames('b0000000-0000-0000-0000-000000000001') $$,
|
||||||
|
'42501'::char(5), null,
|
||||||
|
'carol (no access to the diagram at all) cannot resolve its collaborator usernames'
|
||||||
|
);
|
||||||
|
|
||||||
select is(
|
select is(
|
||||||
(select count(*)::int from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'),
|
(select count(*)::int from public.diagrams where id = 'b0000000-0000-0000-0000-000000000001'),
|
||||||
0,
|
0,
|
||||||
@@ -193,6 +205,74 @@ select is(
|
|||||||
'dave (super admin) can see any diagram'
|
'dave (super admin) can see any diagram'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Username lookup (organized-ideas.md §8's "share with @username" — any
|
||||||
|
-- authenticated user can resolve one, unlike general profile browsing).
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select public.find_user_id_by_username('alice')),
|
||||||
|
'11111111-1111-1111-1111-111111111111'::uuid,
|
||||||
|
'any authenticated user can resolve a username to an id'
|
||||||
|
);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select public.find_user_id_by_username('no-such-user')),
|
||||||
|
null,
|
||||||
|
'resolving an unknown username returns null, not an error'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
-- Snapshot retention (organized-ideas.md §8: keep the 50 most recent per
|
||||||
|
-- diagram). Explicit, staggered created_at values below because pgTAP runs
|
||||||
|
-- inside one transaction — every row would otherwise share the exact same
|
||||||
|
-- now(), making "most recent" ambiguous for this test specifically (a
|
||||||
|
-- real editing session naturally spreads saves out over wall-clock time).
|
||||||
|
-- ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true);
|
||||||
|
|
||||||
|
insert into public.diagram_snapshots (diagram_id, data, saved_by, created_at)
|
||||||
|
select 'b0000000-0000-0000-0000-000000000001', jsonb_build_object('seq', g), '11111111-1111-1111-1111-111111111111',
|
||||||
|
now() + (g || ' seconds')::interval
|
||||||
|
from generate_series(1, 51) g;
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select count(*)::int from public.diagram_snapshots where diagram_id = 'b0000000-0000-0000-0000-000000000001'),
|
||||||
|
50,
|
||||||
|
'only the 50 most recent snapshots are kept'
|
||||||
|
);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select count(*)::int from public.diagram_snapshots
|
||||||
|
where diagram_id = 'b0000000-0000-0000-0000-000000000001' and data ->> 'seq' = '1'),
|
||||||
|
0,
|
||||||
|
'the oldest snapshot was the one pruned'
|
||||||
|
);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select count(*)::int from public.diagram_snapshots
|
||||||
|
where diagram_id = 'b0000000-0000-0000-0000-000000000001' and data ->> 'seq' = '51'),
|
||||||
|
1,
|
||||||
|
'the newest snapshot survives'
|
||||||
|
);
|
||||||
|
|
||||||
|
select is(
|
||||||
|
(select username from public.diagram_snapshot_saved_by_usernames('b0000000-0000-0000-0000-000000000001') where user_id = '11111111-1111-1111-1111-111111111111'),
|
||||||
|
'alice',
|
||||||
|
'alice (owner) can resolve who saved this diagram''s snapshots'
|
||||||
|
);
|
||||||
|
|
||||||
|
select set_config('request.jwt.claim.sub', '33333333-3333-3333-3333-333333333333', true);
|
||||||
|
|
||||||
|
select throws_ok(
|
||||||
|
$$ select * from public.diagram_snapshot_saved_by_usernames('b0000000-0000-0000-0000-000000000001') $$,
|
||||||
|
'42501'::char(5), null,
|
||||||
|
'carol (no access to the diagram at all) cannot resolve who saved its snapshots'
|
||||||
|
);
|
||||||
|
|
||||||
|
select set_config('request.jwt.claim.sub', '11111111-1111-1111-1111-111111111111', true);
|
||||||
|
|
||||||
-- ----------------------------------------------------------------------
|
-- ----------------------------------------------------------------------
|
||||||
-- Catalog submissions
|
-- Catalog submissions
|
||||||
-- ----------------------------------------------------------------------
|
-- ----------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user