diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c7dc9b..b7059131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,10 +34,15 @@ Planned version: **1.5.4**. `list_paused_steps` return one snapshot per paused step (step id, label, pause time, reason and redacted variables) instead of a list of step ids. The MCP result field is now `pausedSteps` (#606). +- Folder and workflow lists reuse one set of permission data per user and request, avoiding an + additional database query for each folder while preserving access rules (#576). ### Fixed - **Designer** + - Read-only workflows keep note and group editing controls disabled. Collapsing or expanding a + group changes only the local view, without autosave or an unsaved-changes warning. Save and + publish also stop queuing writes when the edit lock is lost (#584). - Renaming an output variable updates every reference to it: templates in any field, a dynamic target machine or credential, breakpoint conditions, decision cases and edge conditions, including templates in their fixed comparison values (#689, #789). @@ -128,6 +133,9 @@ Planned version: **1.5.4**. - The dashboard, Live-Ops and the execution pages update live again instead of waiting for their polling interval, and catch up after a reconnect (#706). - **Examples, test suite and tooling** + - CLI tests now verify write requests and their payloads. Telemetry + tests verify actual log delivery over HTTP and gRPC, including collector headers and disabled + export settings. - The Windows Update health example applies `freshnessDays` (#734). The temp-file cleanup example writes a new archive per run and keeps the earlier ones (#723). The README showcase reports a failed probe instead of success (#724). The tech demo uses one name for its child diff --git a/src/NodePilot.Api/Security/ResourceAuthorizationService.cs b/src/NodePilot.Api/Security/ResourceAuthorizationService.cs index 64a1812b..ef252ecf 100644 --- a/src/NodePilot.Api/Security/ResourceAuthorizationService.cs +++ b/src/NodePilot.Api/Security/ResourceAuthorizationService.cs @@ -15,7 +15,7 @@ namespace NodePilot.Api.Security; /// list/detail endpoint without leaking permissions across requests. /// /// Permission resolution: walks the folder ancestry from the requested folder up to -/// Root, collects every grant that targets the user (V1: only PrincipalType=User), +/// Root, collects every grant that targets the user or an authority-scoped group, /// and takes the highest . Higher-role-implies-lower /// is encoded in the private OpRequiresRole helper. Global-Admin bypasses the whole chain. /// @@ -32,9 +32,11 @@ public sealed class ResourceAuthorizationService : IResourceAuthorizationService // any caller switching principals mid-scope would otherwise read stale grants. The // cost of keying on a Guid pair is one extra hash bucket per lookup. private readonly Dictionary<(Guid userId, Guid folderId), List> _ancestryGrantsCache = []; + private readonly Dictionary> _principalGrantsCache = []; private readonly Dictionary _accessibleFolderSetCache = []; private readonly Dictionary> _directoryGroupCache = []; private List? _allFoldersCache; + private Dictionary? _foldersByIdCache; public ResourceAuthorizationService(NodePilotDbContext db, IOptions? authenticationPolicy = null) { @@ -100,22 +102,9 @@ public async Task GetAccessibleFolderIdsAsync(ClaimsPrincip if (userId is null) return AccessibleFolderSet.None; if (_accessibleFolderSetCache.TryGetValue(userId.Value, out var cached)) return cached; - // Pull every folder + every grant for this user in two queries, then resolve - // accessibility entirely in memory. Folder count in any realistic deployment is - // bounded (depth 5 means thousands max even at flat-org-of-thousands scale); the - // cost of the in-memory walk is negligible compared to per-row DB hits. + // Visibility and capabilities use the same per-principal grant snapshot. var allFolders = await GetAllFoldersAsync(ct); - var directoryGroups = await GetDirectoryGroupsAsync(userId.Value, ct); - // Group-aware grant lookup: include any (PrincipalType=Group, PrincipalKey IN - // userGroupSids) grant alongside the per-user grants. Local users have an empty - // groupSids list so this branch is a no-op for them. - var candidateGrants = await _db.SharedFolderPermissions - .AsNoTracking() - .Where(DirectoryGroupPrincipal.GrantPredicate(userId.Value, directoryGroups)) - .ToListAsync(ct); - var grants = DirectoryGroupPrincipal.ExactMatches(candidateGrants, directoryGroups) - .Select(permission => new { permission.FolderId, permission.Role }) - .ToList(); + var grants = await GetPrincipalGrantsAsync(userId.Value, ct); if (grants.Count == 0) { @@ -125,7 +114,7 @@ public async Task GetAccessibleFolderIdsAsync(ClaimsPrincip // Each grant on a folder F implicitly grants access to F + all descendants of F. // Collect the descendant set per granted folder, union them all. - var grantedFolderIds = grants.Select(g => g.FolderId).ToHashSet(); + var grantedFolderIds = grants.Select(g => g.Key).ToHashSet(); var byParent = allFolders.GroupBy(f => f.ParentFolderId) .ToDictionary(g => g.Key ?? Guid.Empty, g => g.ToList()); @@ -157,9 +146,11 @@ public async Task GetAccessibleFolderIdsAsync(ClaimsPrincip public void InvalidateAll() { _ancestryGrantsCache.Clear(); + _principalGrantsCache.Clear(); _accessibleFolderSetCache.Clear(); _directoryGroupCache.Clear(); _allFoldersCache = null; + _foldersByIdCache = null; } public async Task GetWorkflowCapabilitiesAsync(ClaimsPrincipal user, Guid folderId, CancellationToken ct = default) @@ -210,8 +201,7 @@ public Task GetFolderCapabilitiesAsync(ClaimsPrincipal use var userId = TryGetUserId(user); if (userId is null) return null; - var directoryGroups = await GetDirectoryGroupsAsync(userId.Value, ct); - var grants = await GetAncestryGrantsAsync(folderId, userId.Value, directoryGroups, ct); + var grants = await GetAncestryGrantsAsync(folderId, userId.Value, ct); if (grants.Count == 0) return null; // Highest role wins. Enum is ordered FolderViewer < FolderOperator < FolderEditor < @@ -222,21 +212,18 @@ public Task GetFolderCapabilitiesAsync(ClaimsPrincipal use /// /// Returns every that grants access to the /// folder at for — including - /// grants on any ancestor folder, and any authority-scoped group grant represented by - /// . Cached per (folderId, userId) tuple within the - /// request scope (group-membership doesn't change mid-request). + /// grants on any ancestor folder, and any authority-scoped group grant. + /// Cached per (folderId, userId) tuple within the request scope. /// private async Task> GetAncestryGrantsAsync( Guid folderId, Guid userId, - IReadOnlyCollection directoryGroups, CancellationToken ct) { var cacheKey = (userId, folderId); if (_ancestryGrantsCache.TryGetValue(cacheKey, out var cached)) return cached; // Build the ancestor chain folderId -> ... -> Root. - var allFolders = await GetAllFoldersAsync(ct); - var byId = allFolders.ToDictionary(f => f.Id); + var byId = _foldersByIdCache ??= (await GetAllFoldersAsync(ct)).ToDictionary(f => f.Id); var chain = new List(); var current = folderId; var depth = 0; @@ -254,13 +241,22 @@ private async Task> GetAncestryGrantsAsync( return _ancestryGrantsCache[cacheKey]; } - var candidateGrants = await _db.SharedFolderPermissions - .AsNoTracking() - .Where(p => chain.Contains(p.FolderId)) + var principalGrants = await GetPrincipalGrantsAsync(userId, ct); + var grants = chain.SelectMany(id => principalGrants[id]).ToList(); + _ancestryGrantsCache[cacheKey] = grants; + return grants; + } + + private async Task> GetPrincipalGrantsAsync(Guid userId, CancellationToken ct) + { + if (_principalGrantsCache.TryGetValue(userId, out var cached)) return cached; + var directoryGroups = await GetDirectoryGroupsAsync(userId, ct); + var candidates = await _db.SharedFolderPermissions.AsNoTracking() .Where(DirectoryGroupPrincipal.GrantPredicate(userId, directoryGroups)) .ToListAsync(ct); - var grants = DirectoryGroupPrincipal.ExactMatches(candidateGrants, directoryGroups); - _ancestryGrantsCache[cacheKey] = grants; + var grants = DirectoryGroupPrincipal.ExactMatches(candidates, directoryGroups) + .ToLookup(permission => permission.FolderId); + _principalGrantsCache[userId] = grants; return grants; } diff --git a/src/nodepilot-ui/src/__tests__/hooks/useDisplayedGraph.test.tsx b/src/nodepilot-ui/src/__tests__/hooks/useDisplayedGraph.test.tsx index fd33a3b8..33920a4a 100644 --- a/src/nodepilot-ui/src/__tests__/hooks/useDisplayedGraph.test.tsx +++ b/src/nodepilot-ui/src/__tests__/hooks/useDisplayedGraph.test.tsx @@ -42,6 +42,29 @@ function edgeData(edges: Edge[], id: string) { return (edges.find((e) => e.id === id)!.data ?? {}) as Record; } +it('expands a saved collapsed group for viewing without changing the authored graph', () => { + const nodes: Node[] = [ + { id: 'group', type: 'group', position: { x: 0, y: 0 }, style: { width: 240, height: 74 }, + data: { collapsed: true, expandedSize: { width: 500, height: 300 } } }, + { ...NODES[0], parentId: 'group' }, + NODES[1], + ]; + const edges: Edge[] = [{ id: 'child-edge', source: 'a', target: 'b' }]; + const authored = structuredClone({ nodes, edges }); + const { result, rerender } = renderHook(({ overrides }) => useDisplayedGraph(baseArgs({ + nodes, edges, groupCollapseOverrides: overrides, + })), { initialProps: { overrides: new Map([['group', false]]) } }); + expect(result.current.displayedNodes.map((node) => node.id)).toEqual(['group', 'a', 'b']); + expect(result.current.displayedNodes[0].style).toMatchObject({ width: 500, height: 300 }); + expect(result.current.displayedEdges[0]).toMatchObject({ source: 'a', target: 'b' }); + expect({ nodes, edges }).toEqual(authored); + + rerender({ overrides: new Map() }); + expect(result.current.displayedNodes.map((node) => node.id)).toEqual(['group', 'b']); + expect(result.current.displayedEdges[0]).toMatchObject({ source: 'group', target: 'b' }); + expect({ nodes, edges }).toEqual(authored); +}); + describe('useDisplayedGraph — edge detach marker', () => { const cleanEdge: Edge = { id: 'e1', source: 'a', target: 'b', type: 'labeled', data: { label: 'On Success' } }; /** An edge carrying a marker from an earlier session, e.g. a definition saved before the diff --git a/src/nodepilot-ui/src/__tests__/hooks/useWorkflowPersistence.test.tsx b/src/nodepilot-ui/src/__tests__/hooks/useWorkflowPersistence.test.tsx index 2b0d19ec..585ef20d 100644 --- a/src/nodepilot-ui/src/__tests__/hooks/useWorkflowPersistence.test.tsx +++ b/src/nodepilot-ui/src/__tests__/hooks/useWorkflowPersistence.test.tsx @@ -44,7 +44,7 @@ function makeWrapper() { function renderPersistence() { return renderHook( - () => useWorkflowPersistence({ workflowId: 'wf-1', workflow: undefined, nodes: [], edges: [] }), + () => useWorkflowPersistence({ workflowId: 'wf-1', workflow: undefined, canWrite: true, nodes: [], edges: [] }), { wrapper: makeWrapper() }, ); } @@ -64,7 +64,7 @@ describe('useWorkflowPersistence — revision-safe requests', () => { it('keeps the new workflow dirty when an old unlock completes after navigation', async () => { const unlock = deferred(); const { result, rerender } = renderHook(({ id }) => useWorkflowPersistence({ - workflowId: id, workflow: undefined, nodes: [], edges: [], + workflowId: id, workflow: undefined, canWrite: true, nodes: [], edges: [], }), { initialProps: { id: 'a' }, wrapper: makeWrapper() }); act(() => { result.current.syncFromServer('A', 1); result.current.markDirty(); }); let pending!: Promise; @@ -80,13 +80,54 @@ describe('useWorkflowPersistence — revision-safe requests', () => { vi.mocked(api.post).mockReset(); }); + it('stops autosave and explicit writes after write access is lost, and resumes after reacquiring it', async () => { + vi.useFakeTimers(); + try { + vi.mocked(api.put).mockResolvedValue(undefined); + const { result, rerender } = renderHook(({ canWrite }) => useWorkflowPersistence({ + workflowId: 'wf-1', workflow: undefined, nodes: [], edges: [], canWrite, + }), { initialProps: { canWrite: true }, wrapper: makeWrapper() }); + act(() => { result.current.syncFromServer('Draft', 1); result.current.markDirty(); }); + rerender({ canWrite: false }); + + await act(async () => { await vi.advanceTimersByTimeAsync(6000); }); + expect(api.put).not.toHaveBeenCalled(); + await act(async () => { await result.current.saveAsync(); result.current.publish(); }); + expect(api.put).not.toHaveBeenCalled(); + expect(api.post).not.toHaveBeenCalled(); + expect(result.current.isDirty).toBe(true); + + rerender({ canWrite: true }); + await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); + expect(api.put).toHaveBeenCalledTimes(1); + expect(result.current.isDirty).toBe(false); + } finally { vi.useRealTimers(); } + }); + + it.each([false, true])('does not follow up a running save after write access is lost (publish queued: %s)', async (queuePublish) => { + const saving = deferred(); + vi.mocked(api.put).mockImplementationOnce(() => saving.promise); + const { result, rerender } = renderHook(({ canWrite }) => useWorkflowPersistence({ + workflowId: 'wf-1', workflow: undefined, nodes: [], edges: [], canWrite, + }), { initialProps: { canWrite: true }, wrapper: makeWrapper() }); + act(() => { result.current.syncFromServer('Draft', 1); result.current.markDirty(); result.current.save(); }); + await waitFor(() => expect(api.put).toHaveBeenCalledTimes(1)); + act(() => { result.current.markDirty(); if (queuePublish) result.current.publish(); }); + rerender({ canWrite: false }); + await act(async () => saving.resolve(undefined)); + expect(api.put).toHaveBeenCalledTimes(1); + expect(api.post).not.toHaveBeenCalled(); + expect(result.current.isPublishing).toBe(false); + expect(result.current.isDirty).toBe(true); + }); + it.each(['quick-connect', 'edge-insert'])('autosaves a clean draft after %s adds a node', async (operation) => { vi.useFakeTimers(); try { const { result } = renderHook(() => { const [nodes, setNodes] = React.useState([editableNode('Source')]); const [edges, setEdges] = React.useState([{ id: 'edge-1', source: 'step-1', target: 'step-2' }]); - const persistence = useWorkflowPersistence({ workflowId: 'wf-1', workflow: undefined, nodes, edges }); + const persistence = useWorkflowPersistence({ workflowId: 'wf-1', workflow: undefined, canWrite: true, nodes, edges }); const { commitHistory } = useWorkflowHistory('wf-1', { nodes, edges, setNodes, setEdges }, persistence.markDirty); const connect = useCanvasConnect({ edges, setNodes, setEdges, setSelected: () => {}, commitHistory, markDirty: persistence.markDirty, @@ -121,7 +162,7 @@ describe('useWorkflowPersistence — revision-safe requests', () => { const wrapper = makeWrapper(); const { result, rerender } = renderHook( ({ label }) => useWorkflowPersistence({ - workflowId: 'wf-1', workflow: undefined, nodes: [editableNode(label)], edges: [], + workflowId: 'wf-1', workflow: undefined, canWrite: true, nodes: [editableNode(label)], edges: [], }), { wrapper, initialProps: { label: 'revision-a' } }, ); @@ -155,7 +196,7 @@ describe('useWorkflowPersistence — revision-safe requests', () => { const wrapper = makeWrapper(); const { result, rerender } = renderHook( ({ label }) => useWorkflowPersistence({ - workflowId: 'wf-1', workflow: undefined, nodes: [editableNode(label)], edges: [], + workflowId: 'wf-1', workflow: undefined, canWrite: true, nodes: [editableNode(label)], edges: [], }), { wrapper, initialProps: { label: 'published-snapshot' } }, ); @@ -247,7 +288,7 @@ describe('useWorkflowPersistence — revision-safe requests', () => { const wrapper = makeWrapper(); const { result, rerender } = renderHook( ({ workflowId }) => useWorkflowPersistence({ - workflowId, workflow: undefined, nodes: [editableNode('initial')], edges: [], + workflowId, workflow: undefined, canWrite: true, nodes: [editableNode('initial')], edges: [], }), { wrapper, initialProps: { workflowId: 'wf-1' } }, ); @@ -284,7 +325,7 @@ describe('useWorkflowPersistence — revision-safe requests', () => { vi.mocked(api.post).mockResolvedValue({}); const { result, rerender } = renderHook( ({ workflowId }) => useWorkflowPersistence({ - workflowId, workflow: undefined, nodes: [editableNode(workflowId)], edges: [], + workflowId, workflow: undefined, canWrite: true, nodes: [editableNode(workflowId)], edges: [], }), { wrapper: makeWrapper(), initialProps: { workflowId: 'wf-1' } }, ); diff --git a/src/nodepilot-ui/src/__tests__/pages/WorkflowEditorPage.test.tsx b/src/nodepilot-ui/src/__tests__/pages/WorkflowEditorPage.test.tsx index af909803..e6312a91 100644 --- a/src/nodepilot-ui/src/__tests__/pages/WorkflowEditorPage.test.tsx +++ b/src/nodepilot-ui/src/__tests__/pages/WorkflowEditorPage.test.tsx @@ -233,6 +233,108 @@ async function waitForCanvasReady() { await waitFor(() => expect(document.querySelector('.react-flow__node')).not.toBeNull()); } +describe('WorkflowEditorPage — annotation write access', () => { + const definition = { + nodes: [ + { id: 'group-1', type: 'group', position: { x: 0, y: 0 }, selected: true, + style: { width: 500, height: 250 }, + data: { label: 'Review group', color: 'blue', collapsed: false, disabled: true } }, + { id: 'note-1', type: 'stickyNote', parentId: 'group-1', position: { x: 40, y: 60 }, + width: 200, height: 100, selected: true, + data: { text: 'Review note', fontSize: 13, disabled: true } }, + ], + edges: [], + }; + + it.each(['Viewer', 'Operator'] as const)('%s can collapse groups locally without modifying or saving annotations', async (role) => { + const put = vi.fn(); + server.use( + http.get(`${BASE}/api/workflows/wf-smoke-1`, () => HttpResponse.json({ + ...MOCK_DISABLED, definitionJson: JSON.stringify(definition), + })), + http.put(`${BASE}/api/workflows/wf-smoke-1`, () => { put(); return HttpResponse.json(MOCK_DISABLED); }), + ); + const view = renderPage(role); + try { + await screen.findByText('Review note'); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + fireEvent.click(screen.getByTitle('Collapse group')); + expect(screen.queryByText('Review note')).not.toBeInTheDocument(); + const unload = new Event('beforeunload', { cancelable: true }); + globalThis.dispatchEvent(unload); + expect(unload.defaultPrevented).toBe(false); + await act(async () => { await vi.advanceTimersByTimeAsync(6000); }); + expect(put).not.toHaveBeenCalled(); + fireEvent.click(screen.getByTitle('Expand group')); + expect(screen.getByText('Review note')).toBeInTheDocument(); + expect(screen.queryByTitle('Larger text')).not.toBeInTheDocument(); + expect(screen.queryByTitle('rose')).not.toBeInTheDocument(); + expect(view.container.querySelector('.react-flow__resize-control')).not.toBeInTheDocument(); + expect(useToastStore.getState().toasts).toHaveLength(0); + } finally { vi.useRealTimers(); view.unmount(); } + }); + + it('keeps annotation editing and persisted group collapse available to the lock owner', async () => { + const writes: Array<{ definitionJson: string }> = []; + server.use( + http.get(`${BASE}/api/workflows/wf-smoke-1`, () => HttpResponse.json({ + ...MOCK_WORKFLOW, definitionJson: JSON.stringify(definition), + })), + http.put(`${BASE}/api/workflows/wf-smoke-1`, async ({ request }) => { + writes.push(await request.json() as { definitionJson: string }); + return new HttpResponse(null, { status: 204 }); + }), + ); + renderPage(); + fireEvent.click(await screen.findByTitle('Larger text')); + fireEvent.doubleClick(screen.getByText('Review note')); + const text = screen.getByDisplayValue('Review note'); + fireEvent.change(text, { target: { value: 'Edited note' } }); + fireEvent.blur(text); + fireEvent.click(screen.getByTitle('rose')); + fireEvent.doubleClick(screen.getByText('Review group')); + const label = screen.getByDisplayValue('Review group'); + fireEvent.change(label, { target: { value: 'Renamed group' } }); + fireEvent.keyDown(label, { key: 'Enter' }); + fireEvent.click(screen.getByTitle('Collapse group')); + fireEvent.keyDown(window, { key: 's', ctrlKey: true }); + await waitFor(() => expect(writes).toHaveLength(1)); + const saved = JSON.parse(writes[0].definitionJson); + expect(saved.nodes.find((node: { id: string }) => node.id === 'group-1').data) + .toMatchObject({ label: 'Renamed group', color: 'rose', collapsed: true }); + expect(saved.nodes.find((node: { id: string }) => node.id === 'note-1').data) + .toMatchObject({ fontSize: 16, text: 'Edited note' }); + }); + + it('does not persist a read-only collapse when the operator later starts editing', async () => { + let locked = false; + const writes: Array<{ definitionJson: string }> = []; + server.use( + http.get(`${BASE}/api/workflows/wf-smoke-1`, () => HttpResponse.json({ + ...(locked ? MOCK_WORKFLOW : MOCK_DISABLED), definitionJson: JSON.stringify(definition), + })), + http.post(`${BASE}/api/workflows/wf-smoke-1/lock`, () => { + locked = true; + return HttpResponse.json(MOCK_WORKFLOW); + }), + http.put(`${BASE}/api/workflows/wf-smoke-1`, async ({ request }) => { + writes.push(await request.json() as { definitionJson: string }); + return new HttpResponse(null, { status: 204 }); + }), + ); + renderPage('Operator'); + fireEvent.click(await screen.findByTitle('Collapse group')); + expect(screen.queryByText('Review note')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /^Edit$/i })); + fireEvent.click(await screen.findByTitle('Larger text')); + fireEvent.keyDown(window, { key: 's', ctrlKey: true }); + await waitFor(() => expect(writes).toHaveLength(1)); + const group = JSON.parse(writes[0].definitionJson).nodes.find((node: { id: string }) => node.id === 'group-1'); + expect(group.data.collapsed).toBe(false); + expect(group.style).toMatchObject({ width: 500, height: 250 }); + }); +}); + it('undo restores the property value from before a debounced edit burst', async () => { const view = renderPage(); try { diff --git a/src/nodepilot-ui/src/components/designer/nodes/AnnotationEditContext.ts b/src/nodepilot-ui/src/components/designer/nodes/AnnotationEditContext.ts new file mode 100644 index 00000000..15503cad --- /dev/null +++ b/src/nodepilot-ui/src/components/designer/nodes/AnnotationEditContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react'; +import type { Node } from '@xyflow/react'; + +/** Annotation editors share the canvas permission; previews default to read-only. */ +export const AnnotationEditContext = createContext<{ + canWrite: boolean; + updateGroupNode: (id: string, updater: (node: Node) => Node) => void; + toggleGroupView: (id: string) => void; +} | null>(null); diff --git a/src/nodepilot-ui/src/components/designer/nodes/GroupNode.tsx b/src/nodepilot-ui/src/components/designer/nodes/GroupNode.tsx index 4a86636a..8b2ec41b 100644 --- a/src/nodepilot-ui/src/components/designer/nodes/GroupNode.tsx +++ b/src/nodepilot-ui/src/components/designer/nodes/GroupNode.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { type NodeProps, type Node, NodeResizer, useReactFlow, useStore } from '@xyflow/react'; import { getCollapsedGroupSize, getExpandedGroupSize } from '../../../lib/collapsedGraphView'; import { groupLabelFontSize } from '../../../lib/groupLabel'; +import { AnnotationEditContext } from './AnnotationEditContext'; type GroupColor = typeof COLORS[number]; @@ -11,10 +12,6 @@ type GroupColor = typeof COLORS[number]; // The hue is user-picked data (9 fixed colors) resolved through `--np-group-` tokens. const COLORS = ['blue', 'teal', 'green', 'amber', 'orange', 'rose', 'pink', 'violet', 'slate'] as const; -export const GroupNodeEditContext = createContext<{ - updateGroupNode: (id: string, updater: (node: Node) => Node) => void; -} | null>(null); - /** * Id of the group currently highlighted as the drag drop-target, or null. Set by the editor during * a node drag (see WorkflowEditorPage.onNodeDrag). Default null so the read-only preview renderer, @@ -35,7 +32,8 @@ export function GroupNode({ id, data, selected }: NodeProps) { const collapsed = d.collapsed === true; const childCount = typeof d.__collapsedChildCount === 'number' ? d.__collapsedChildCount : 0; const { setNodes } = useReactFlow(); - const editContext = useContext(GroupNodeEditContext); + const editContext = useContext(AnnotationEditContext); + const canWrite = editContext?.canWrite ?? false; const isDropTarget = useContext(GroupDropTargetContext) === id; // Zoom-aware label size: grows when zooming in, stays readable when zooming out. Subscribed via // selector so the node only re-renders on zoom change, not on pan/selection. @@ -55,7 +53,7 @@ export function GroupNode({ id, data, selected }: NodeProps) { const updateGroupNode = useCallback((updater: (node: Node) => Node) => { if (editContext) { - editContext.updateGroupNode(id, updater); + if (editContext.canWrite) editContext.updateGroupNode(id, updater); return; } setNodes((nds: Node[]) => nds.map((n) => n.id === id ? updater(n) : n)); @@ -63,6 +61,7 @@ export function GroupNode({ id, data, selected }: NodeProps) { const commit = () => { setEditing(false); + if (!canWrite) return; const trimmed = draft.trim() || 'Group'; if (trimmed === savedLabel) return; updateGroupNode((n) => ({ @@ -72,6 +71,7 @@ export function GroupNode({ id, data, selected }: NodeProps) { }; const setColor = (c: GroupColor) => { + if (!canWrite) return; updateGroupNode((n) => ({ ...n, data: { ...withoutRuntimeGroupData(n.data as Record), color: c }, @@ -79,6 +79,10 @@ export function GroupNode({ id, data, selected }: NodeProps) { }; const toggleCollapsed = () => { + if (editContext && !canWrite) { + editContext.toggleGroupView(id); + return; + } updateGroupNode((n) => { const data = withoutRuntimeGroupData((n.data as Record) ?? {}); if (collapsed) { @@ -105,7 +109,7 @@ export function GroupNode({ id, data, selected }: NodeProps) { @@ -117,7 +121,7 @@ export function GroupNode({ id, data, selected }: NodeProps) { {/* Header: label chip on the left, actions on the right (shown on hover/selection). */}
- {editing ? ( + {canWrite && editing ? ( ) : ( { e.stopPropagation(); setEditing(true); }} - title={t('nodes.group.rename')} + onDoubleClick={(e) => { e.stopPropagation(); if (canWrite) setEditing(true); }} + title={canWrite ? t('nodes.group.rename') : undefined} > {savedLabel} @@ -144,7 +148,7 @@ export function GroupNode({ id, data, selected }: NodeProps) {
{/* Color swatches — only visible when selected */} - {selected && !editing && COLORS.map((c) => ( + {canWrite && selected && !editing && COLORS.map((c) => (