canopycms 0.0.68-int.111 → 0.0.68-int.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/branch-health.js +14 -9
  2. package/dist/cli/cli.js +4 -0
  3. package/dist/cli/generate-ai-content.js +2 -0
  4. package/dist/editor/Editor.js +2 -2
  5. package/dist/editor/components/StagedChangesDrawer.d.ts +37 -0
  6. package/dist/editor/components/StagedChangesDrawer.js +75 -0
  7. package/dist/editor/group-manager/GroupCard.d.ts +2 -0
  8. package/dist/editor/group-manager/GroupCard.js +2 -2
  9. package/dist/editor/group-manager/GroupForm.js +1 -1
  10. package/dist/editor/group-manager/InternalGroupsTab.d.ts +1 -0
  11. package/dist/editor/group-manager/InternalGroupsTab.js +2 -2
  12. package/dist/editor/group-manager/hooks/useGroupState.d.ts +2 -0
  13. package/dist/editor/group-manager/hooks/useGroupState.js +18 -1
  14. package/dist/editor/group-manager/index.js +9 -4
  15. package/dist/editor/group-manager/types.d.ts +3 -1
  16. package/dist/editor/hooks/usePermissionManager.js +4 -3
  17. package/dist/editor/permission-manager/index.js +9 -3
  18. package/dist/editor/permission-manager/types.d.ts +3 -1
  19. package/dist/git-manager.d.ts +10 -0
  20. package/dist/git-manager.js +6 -5
  21. package/dist/utils/error.js +6 -0
  22. package/dist/version.d.ts +1 -1
  23. package/dist/version.js +1 -1
  24. package/dist/worker/cms-worker.d.ts +39 -124
  25. package/dist/worker/cms-worker.js +232 -259
  26. package/dist/worker/git-sync.d.ts +7 -5
  27. package/dist/worker/git-sync.js +32 -29
  28. package/dist/worker/github-auth.d.ts +7 -7
  29. package/dist/worker/github-auth.js +12 -14
  30. package/dist/worker/github-gateway.d.ts +144 -0
  31. package/dist/worker/github-gateway.js +370 -0
  32. package/dist/worker/github-mirror.d.ts +84 -10
  33. package/dist/worker/github-mirror.js +237 -21
  34. package/dist/worker/rebase.d.ts +1 -1
  35. package/dist/worker/rebase.js +1 -6
  36. package/dist/worker/shared-repo-git.d.ts +6 -0
  37. package/dist/worker/shared-repo-git.js +8 -0
  38. package/dist/worker/task-runner.d.ts +2 -2
  39. package/dist/worker/task-runner.js +77 -127
  40. package/dist/worker/worker-context.d.ts +12 -40
  41. package/package.json +1 -1
@@ -45,15 +45,17 @@ async function readMetaMtime(branchRoot) {
45
45
  }
46
46
  /**
47
47
  * Scan a healthy branch's content tree for duplicate embedded IDs, giving up
48
- * at `deadline` (epoch ms). Never throws: one branch's unreadable content tree
48
+ * when `budget` runs out. Never throws: one branch's unreadable content tree
49
49
  * must not take down the whole health scan. Costs a full recursive readdir, so
50
50
  * it runs only on request and under a budget. A readdir cannot be cancelled: a
51
51
  * scan that misses the deadline keeps running unobserved until it settles.
52
52
  */
53
- async function scanDuplicateContentIds(branchRoot, contentRootName, deadline) {
54
- const remainingMs = deadline - Date.now();
55
- if (remainingMs <= 0)
53
+ async function scanDuplicateContentIds(branchRoot, contentRootName, budget) {
54
+ const remainingMs = budget.deadline - Date.now();
55
+ if (budget.spent || remainingMs <= 0) {
56
+ budget.spent = true;
56
57
  return { state: 'unknown', reason: 'out-of-time' };
58
+ }
57
59
  const scan = (async () => {
58
60
  try {
59
61
  const idIndex = new ContentIdIndex(branchRoot);
@@ -67,7 +69,10 @@ async function scanDuplicateContentIds(branchRoot, contentRootName, deadline) {
67
69
  })();
68
70
  let timer;
69
71
  const outOfTime = new Promise((resolve) => {
70
- timer = setTimeout(() => resolve({ state: 'unknown', reason: 'out-of-time' }), remainingMs);
72
+ timer = setTimeout(() => {
73
+ budget.spent = true;
74
+ resolve({ state: 'unknown', reason: 'out-of-time' });
75
+ }, remainingMs);
71
76
  });
72
77
  try {
73
78
  return await Promise.race([scan, outOfTime]);
@@ -88,8 +93,8 @@ async function scanDuplicateContentIds(branchRoot, contentRootName, deadline) {
88
93
  * reached after the budget is spent reports `unknown`/`out-of-time`.
89
94
  */
90
95
  export async function scanBranchHealth(baseRoot, opts) {
91
- const duplicateScanDeadline = opts.duplicateIdScan
92
- ? Date.now() + opts.duplicateIdScan.budgetMs
96
+ const duplicateScanBudget = opts.duplicateIdScan
97
+ ? { deadline: Date.now() + opts.duplicateIdScan.budgetMs, spent: false }
93
98
  : undefined;
94
99
  const resolvedRoot = path.resolve(baseRoot);
95
100
  const sanitizedBaseBranchName = sanitizeBranchName(opts.baseBranchName);
@@ -154,9 +159,9 @@ export async function scanBranchHealth(baseRoot, opts) {
154
159
  }
155
160
  if (meta) {
156
161
  const [duplicateIdScan, rebaseInProgress] = await Promise.all([
157
- duplicateScanDeadline === undefined
162
+ duplicateScanBudget === undefined
158
163
  ? undefined
159
- : scanDuplicateContentIds(branchRoot, contentRootName, duplicateScanDeadline),
164
+ : scanDuplicateContentIds(branchRoot, contentRootName, duplicateScanBudget),
160
165
  isRebaseInProgress(branchRoot),
161
166
  ]);
162
167
  entries.push({
package/dist/cli/cli.js CHANGED
@@ -22,6 +22,8 @@ function getErrorMessage(err2) {
22
22
  function redactCredentials(message) {
23
23
  let result = message;
24
24
  result = result.replace(/(:\/\/)[^/\s@]+@/g, "$1***@");
25
+ result = result.replace(/(\bauthorization\s*:\s*(?:basic|bearer|token)\s+)[A-Za-z0-9+/=._~-]+/gi, "$1***");
26
+ result = result.replace(/eC1hY2Nlc3MtdG9rZW46[A-Za-z0-9+/]*={0,2}/g, "***");
25
27
  result = result.replace(/\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{8,}/g, "***");
26
28
  result = result.replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/g, "$1***");
27
29
  result = result.replace(/-----BEGIN [A-Z]{0,9} ?PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z]{0,9} ?PRIVATE KEY-----|$)/g, "<private-key>");
@@ -3233,6 +3235,8 @@ function getErrorMessage3(err2) {
3233
3235
  function redactCredentials2(message) {
3234
3236
  let result = message;
3235
3237
  result = result.replace(/(:\/\/)[^/\s@]+@/g, "$1***@");
3238
+ result = result.replace(/(\bauthorization\s*:\s*(?:basic|bearer|token)\s+)[A-Za-z0-9+/=._~-]+/gi, "$1***");
3239
+ result = result.replace(/eC1hY2Nlc3MtdG9rZW46[A-Za-z0-9+/]*={0,2}/g, "***");
3236
3240
  result = result.replace(/\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{8,}/g, "***");
3237
3241
  result = result.replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/g, "$1***");
3238
3242
  result = result.replace(/-----BEGIN [A-Z]{0,9} ?PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z]{0,9} ?PRIVATE KEY-----|$)/g, "<private-key>");
@@ -21,6 +21,8 @@ function getErrorMessage(err) {
21
21
  function redactCredentials(message) {
22
22
  let result = message;
23
23
  result = result.replace(/(:\/\/)[^/\s@]+@/g, "$1***@");
24
+ result = result.replace(/(\bauthorization\s*:\s*(?:basic|bearer|token)\s+)[A-Za-z0-9+/=._~-]+/gi, "$1***");
25
+ result = result.replace(/eC1hY2Nlc3MtdG9rZW46[A-Za-z0-9+/]*={0,2}/g, "***");
24
26
  result = result.replace(/\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{8,}/g, "***");
25
27
  result = result.replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/g, "$1***");
26
28
  result = result.replace(/-----BEGIN [A-Z]{0,9} ?PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z]{0,9} ?PRIVATE KEY-----|$)/g, "<private-key>");
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useMemo, useState, useRef } from 'react';
4
- import { ActionIcon, Alert, Box, Drawer, Group, Menu, Paper, Text, Title, } from '@mantine/core';
4
+ import { ActionIcon, Alert, Box, Drawer, Group, Menu, Paper, Text } from '@mantine/core';
5
5
  import { IconChevronDown, IconChevronUp, IconDots, IconFolderPlus, IconPlus, } from '@tabler/icons-react';
6
6
  import { notifications } from '@mantine/notifications';
7
7
  const noop = () => { };
@@ -773,7 +773,7 @@ const EditorContent = ({ entries, title, subtitle, siteTitle = title, siteSubtit
773
773
  handleRequestChanges(name).catch((err) => console.error(err));
774
774
  }, onDelete: (name) => {
775
775
  handleDelete(name).catch((err) => console.error(err));
776
- }, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: currentUser, canResolve: canResolveComments, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: handleGetUserMetadata }) }), commentsPanelOpen && branchNameState && (_jsx(CommentsPanel, { branchName: branchNameState, comments: comments, canResolve: true, onAddComment: handleAddComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: handleJumpToField, onGetUserMetadata: handleGetUserMetadata, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch })), _jsx(Drawer, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), position: "right", title: _jsxs("div", { children: [_jsx(Title, { order: 4, children: "Groups" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Manage groups and organizations" })] }), padding: "md", size: 600, overlayProps: { blur: 2 }, children: _jsx(GroupManager, { internalGroups: groupsData, loading: groupsLoading, canEdit: true, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups, onClose: () => setGroupManagerOpen(false) }) }), _jsx(Drawer, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), position: "right", title: _jsxs("div", { children: [_jsx(Title, { order: 4, children: "Permissions" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Manage content access by path (read, edit, review)" })] }), padding: "md", size: 700, overlayProps: { blur: 2 }, children: _jsx(PermissionManager, { collections: activeCollections, contentRoot: contentRoot, permissions: permissionsData, loading: permissionsLoading, canEdit: true, onSave: handleSavePermissions, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onListGroups: handleListGroups, onClose: () => setPermissionManagerOpen(false) }) }), showSystemHealth && (_jsx(SystemHealthPanel, { opened: systemHealthOpen, onClose: () => setSystemHealthOpen(false) })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage" }), _jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection ? (path, entryType) => addEntryType(path, entryType) : undefined, onUpdateEntryType: editingCollection
776
+ }, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: currentUser, canResolve: canResolveComments, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: handleGetUserMetadata }) }), commentsPanelOpen && branchNameState && (_jsx(CommentsPanel, { branchName: branchNameState, comments: comments, canResolve: true, onAddComment: handleAddComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: handleJumpToField, onGetUserMetadata: handleGetUserMetadata, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch })), _jsx(GroupManager, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), internalGroups: groupsData, loading: groupsLoading, canEdit: true, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups }), _jsx(PermissionManager, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), collections: activeCollections, contentRoot: contentRoot, permissions: permissionsData, loading: permissionsLoading, canEdit: true, onSave: handleSavePermissions, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onListGroups: handleListGroups }), showSystemHealth && (_jsx(SystemHealthPanel, { opened: systemHealthOpen, onClose: () => setSystemHealthOpen(false) })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage" }), _jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection ? (path, entryType) => addEntryType(path, entryType) : undefined, onUpdateEntryType: editingCollection
777
777
  ? (path, name, updates) => updateEntryType(path, name, updates)
778
778
  : undefined, onRemoveEntryType: editingCollection ? (path, name) => removeEntryType(path, name) : undefined, onClose: handleCloseCollectionEditor, isSaving: schemaLoading, error: collectionEditorError }), renamingEntry && (_jsx(RenameEntryModal, { isOpen: renameModalOpen, entryLabel: renamingEntry.label, currentSlug: renamingEntry.slug || '', onSave: handleRenameSubmit, onClose: () => {
779
779
  setRenameModalOpen(false);
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ export interface StagedChangesDrawerProps {
3
+ opened: boolean;
4
+ /** Closes the drawer. Called directly when clean, only after a confirmed discard when dirty, and never while saving. */
5
+ onClose: () => void;
6
+ title: string;
7
+ description: string;
8
+ size: number;
9
+ isDirty: boolean;
10
+ isSaving: boolean;
11
+ /** Whether the viewer may save; without it the save bar never renders. */
12
+ canEdit: boolean;
13
+ saveLabel: string;
14
+ onSave: () => void;
15
+ onDiscard: () => void;
16
+ /** A modal of the panel's own is open: the drawer yields Escape to it (Mantine listens for Escape on window, capture phase). */
17
+ childModalOpen?: boolean;
18
+ /** Runs once the close transition has finished; see `useOpeningKey`. */
19
+ onExited?: () => void;
20
+ children: React.ReactNode;
21
+ }
22
+ /**
23
+ * A key that changes each time the drawer finishes closing. A panel keyed with it, and passing
24
+ * `onExited` through, gets fresh state for each opening, as the drawer's own children do. A
25
+ * reopen during the close transition cancels it, so that opening keeps the previous state.
26
+ */
27
+ export declare function useOpeningKey(): {
28
+ key: number;
29
+ onExited: () => void;
30
+ };
31
+ /**
32
+ * The drawer shell for an admin panel that stages edits and saves them as one batch
33
+ * (groups, permissions). Staged edits exist only in the panel's state, so the shell keeps
34
+ * them from being lost unnoticed: a title badge and a sticky save bar while dirty, a
35
+ * discard confirm on close, and a `beforeunload` prompt.
36
+ */
37
+ export declare const StagedChangesDrawer: React.FC<StagedChangesDrawerProps>;
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useReducer, useRef } from 'react';
4
+ import { Badge, Button, Drawer, Fieldset, Group, Text, Title } from '@mantine/core';
5
+ import { modals } from '@mantine/modals';
6
+ /**
7
+ * A key that changes each time the drawer finishes closing. A panel keyed with it, and passing
8
+ * `onExited` through, gets fresh state for each opening, as the drawer's own children do. A
9
+ * reopen during the close transition cancels it, so that opening keeps the previous state.
10
+ */
11
+ export function useOpeningKey() {
12
+ const [key, onExited] = useReducer((n) => n + 1, 0);
13
+ return { key, onExited };
14
+ }
15
+ /** Asks the browser to confirm a reload or navigation, for exactly as long as `isDirty` holds. */
16
+ function useBeforeUnloadWhileDirty(isDirty) {
17
+ useEffect(() => {
18
+ if (!isDirty)
19
+ return;
20
+ const handler = (event) => {
21
+ event.preventDefault();
22
+ // Chrome before 119 shows the prompt only when returnValue is set.
23
+ event.returnValue = '';
24
+ };
25
+ window.addEventListener('beforeunload', handler);
26
+ return () => window.removeEventListener('beforeunload', handler);
27
+ }, [isDirty]);
28
+ }
29
+ /**
30
+ * The drawer shell for an admin panel that stages edits and saves them as one batch
31
+ * (groups, permissions). Staged edits exist only in the panel's state, so the shell keeps
32
+ * them from being lost unnoticed: a title badge and a sticky save bar while dirty, a
33
+ * discard confirm on close, and a `beforeunload` prompt.
34
+ */
35
+ export const StagedChangesDrawer = ({ opened, onClose, title, description, size, isDirty, isSaving, canEdit, saveLabel, onSave, onDiscard, childModalOpen = false, onExited, children, }) => {
36
+ // A ref, not state: @mantine/modals runs the confirm's onClose inside its reducer, during
37
+ // render, where setting another component's state draws a React warning. While the confirm is open the
38
+ // drawer still hears Escape, so requestClose must ignore it.
39
+ const confirmOpenRef = useRef(false);
40
+ useBeforeUnloadWhileDirty(isDirty);
41
+ const requestClose = useCallback(() => {
42
+ // The save is already on its way to the server; discarding now could not stop it.
43
+ if (isSaving || confirmOpenRef.current)
44
+ return;
45
+ if (!isDirty) {
46
+ onClose();
47
+ return;
48
+ }
49
+ confirmOpenRef.current = true;
50
+ modals.openConfirmModal({
51
+ title: 'Discard unsaved changes?',
52
+ children: _jsx(Text, { size: "sm", children: "Closing now drops the changes you have not saved." }),
53
+ labels: { confirm: 'Discard', cancel: 'Keep editing' },
54
+ confirmProps: { color: 'red' },
55
+ onConfirm: () => {
56
+ onDiscard();
57
+ onClose();
58
+ },
59
+ // Mantine calls onClose for every exit, the confirm included.
60
+ onClose: () => {
61
+ confirmOpenRef.current = false;
62
+ },
63
+ });
64
+ }, [isDirty, isSaving, onClose, onDiscard]);
65
+ return (_jsxs(Drawer, { opened: opened, onClose: requestClose, closeOnEscape: !childModalOpen, onExitTransitionEnd: onExited, position: "right", title: _jsxs("div", { children: [_jsxs(Group, { gap: "xs", children: [_jsx(Title, { order: 4, children: title }), isDirty && (_jsx(Badge, { color: "orange", variant: "light", children: "Unsaved changes" }))] }), _jsx(Text, { size: "xs", c: "dimmed", children: description })] }), padding: "md", size: size, overlayProps: { blur: 2 }, children: [_jsx(Fieldset, { variant: "unstyled", disabled: isSaving, "aria-busy": isSaving, children: children }), canEdit && isDirty && (
66
+ // The drawer's content box is the scroll container, so a sticky bar stays in view
67
+ // however long the panel's list grows.
68
+ _jsxs(Group, { justify: "space-between", py: "sm", gap: "sm", style: {
69
+ position: 'sticky',
70
+ bottom: 0,
71
+ zIndex: 1,
72
+ background: 'var(--mantine-color-body)',
73
+ borderTop: '1px solid var(--mantine-color-gray-3)',
74
+ }, children: [_jsx(Text, { size: "sm", c: "dimmed", children: "Changes take effect only after you save." }), _jsxs(Group, { gap: "sm", children: [_jsx(Button, { variant: "subtle", color: "neutral", onClick: onDiscard, disabled: isSaving, children: "Discard Changes" }), _jsx(Button, { onClick: onSave, loading: isSaving, disabled: isSaving, children: saveLabel })] })] }))] }));
75
+ };
@@ -2,6 +2,8 @@ import React from 'react';
2
2
  import type { InternalGroup, UserSearchResult, CanopyGroupId, CanopyUserId } from './types.js';
3
3
  export interface GroupCardProps {
4
4
  group: InternalGroup;
5
+ /** The group is new or edited and its change is staged, not saved. */
6
+ isUnsaved: boolean;
5
7
  onEdit: (group: InternalGroup) => void;
6
8
  onDelete: (groupId: CanopyGroupId) => void;
7
9
  onAddMember: (groupId: CanopyGroupId, userId: CanopyUserId) => void;
@@ -3,6 +3,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { ActionIcon, Badge, Group, Paper, Text, Tooltip } from '@mantine/core';
4
4
  import { IconEdit, IconTrash } from '@tabler/icons-react';
5
5
  import { MemberList } from './MemberList.js';
6
- export const GroupCard = ({ group, onEdit, onDelete, onAddMember, onRemoveMember, onGetUserMetadata, showUserSearch, searchQuery, searchResults, isSearching, searchError, onSearchQueryChange, onShowSearch, onHideSearch, canSearch, }) => {
7
- return (_jsxs(Paper, { withBorder: true, p: "sm", mb: "xs", children: [_jsxs(Group, { justify: "space-between", mb: "sm", children: [_jsxs("div", { style: { flex: 1 }, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { size: "sm", fw: 500, children: group.name }), _jsxs(Badge, { size: "sm", variant: "light", children: [group.members?.length || 0, " members"] })] }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["ID: ", group.id] }), group.description && (_jsx(Text, { size: "xs", c: "dimmed", mt: 4, children: group.description }))] }), _jsxs(Group, { gap: "xs", children: [_jsx(Tooltip, { label: "Edit group", children: _jsx(ActionIcon, { size: "sm", variant: "subtle", onClick: () => onEdit(group), children: _jsx(IconEdit, { size: 16 }) }) }), _jsx(Tooltip, { label: "Delete group", children: _jsx(ActionIcon, { size: "sm", variant: "subtle", color: "red", onClick: () => onDelete(group.id), children: _jsx(IconTrash, { size: 16 }) }) })] })] }), _jsx(MemberList, { groupId: group.id, members: group.members || [], onRemoveMember: onRemoveMember, onAddMember: onAddMember, onGetUserMetadata: onGetUserMetadata, showUserSearch: showUserSearch, searchQuery: searchQuery, searchResults: searchResults, isSearching: isSearching, searchError: searchError, onSearchQueryChange: onSearchQueryChange, onShowSearch: onShowSearch, onHideSearch: onHideSearch, canSearch: canSearch })] }));
6
+ export const GroupCard = ({ group, isUnsaved, onEdit, onDelete, onAddMember, onRemoveMember, onGetUserMetadata, showUserSearch, searchQuery, searchResults, isSearching, searchError, onSearchQueryChange, onShowSearch, onHideSearch, canSearch, }) => {
7
+ return (_jsxs(Paper, { withBorder: true, p: "sm", mb: "xs", children: [_jsxs(Group, { justify: "space-between", mb: "sm", children: [_jsxs("div", { style: { flex: 1 }, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { size: "sm", fw: 500, children: group.name }), _jsxs(Badge, { size: "sm", variant: "light", children: [group.members?.length || 0, " members"] }), isUnsaved && (_jsx(Badge, { size: "sm", variant: "light", color: "orange", children: "Unsaved" }))] }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["ID: ", group.id] }), group.description && (_jsx(Text, { size: "xs", c: "dimmed", mt: 4, children: group.description }))] }), _jsxs(Group, { gap: "xs", children: [_jsx(Tooltip, { label: "Edit group", children: _jsx(ActionIcon, { size: "sm", variant: "subtle", onClick: () => onEdit(group), children: _jsx(IconEdit, { size: 16 }) }) }), _jsx(Tooltip, { label: "Delete group", children: _jsx(ActionIcon, { size: "sm", variant: "subtle", color: "red", onClick: () => onDelete(group.id), children: _jsx(IconTrash, { size: 16 }) }) })] })] }), _jsx(MemberList, { groupId: group.id, members: group.members || [], onRemoveMember: onRemoveMember, onAddMember: onAddMember, onGetUserMetadata: onGetUserMetadata, showUserSearch: showUserSearch, searchQuery: searchQuery, searchResults: searchResults, isSearching: isSearching, searchError: searchError, onSearchQueryChange: onSearchQueryChange, onShowSearch: onShowSearch, onHideSearch: onHideSearch, canSearch: canSearch })] }));
8
8
  };
@@ -2,5 +2,5 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Button, Group, Modal, Paper, Stack, Text, TextInput, Textarea } from '@mantine/core';
4
4
  export const GroupForm = ({ isOpen, editingGroup, formData, onFormChange, onSave, onClose, }) => {
5
- return (_jsx(Modal, { opened: isOpen, onClose: onClose, title: editingGroup ? 'Edit Group' : 'Create Group', children: _jsxs(Stack, { gap: "md", children: [editingGroup && (_jsxs("div", { children: [_jsx(Text, { size: "sm", fw: 500, mb: 4, children: "ID (system-generated)" }), _jsx(Paper, { p: "xs", withBorder: true, bg: "gray.0", children: _jsx(Text, { size: "sm", ff: "monospace", c: "dimmed", children: editingGroup.id }) })] })), _jsx(TextInput, { label: "Group Name", placeholder: "e.g., Content Editors", value: formData.name, onChange: (e) => onFormChange({ name: e.target.value }), required: true }), _jsx(Textarea, { label: "Description", placeholder: "Optional description", value: formData.description, onChange: (e) => onFormChange({ description: e.target.value }), rows: 3 }), _jsxs(Group, { justify: "flex-end", gap: "sm", children: [_jsx(Button, { variant: "subtle", onClick: onClose, children: "Cancel" }), _jsx(Button, { onClick: onSave, children: editingGroup ? 'Save' : 'Create' })] })] }) }));
5
+ return (_jsx(Modal, { opened: isOpen, onClose: onClose, title: editingGroup ? 'Edit Group' : 'Create Group', children: _jsxs(Stack, { gap: "md", children: [editingGroup && (_jsxs("div", { children: [_jsx(Text, { size: "sm", fw: 500, mb: 4, children: "ID (system-generated)" }), _jsx(Paper, { p: "xs", withBorder: true, bg: "gray.0", children: _jsx(Text, { size: "sm", ff: "monospace", c: "dimmed", children: editingGroup.id }) })] })), _jsx(TextInput, { label: "Group Name", placeholder: "e.g., Content Editors", value: formData.name, onChange: (e) => onFormChange({ name: e.target.value }), required: true }), _jsx(Textarea, { label: "Description", placeholder: "Optional description", value: formData.description, onChange: (e) => onFormChange({ description: e.target.value }), rows: 3 }), _jsxs(Group, { justify: "flex-end", gap: "sm", children: [_jsx(Button, { variant: "subtle", onClick: onClose, children: "Cancel" }), _jsx(Button, { onClick: onSave, children: editingGroup ? 'Apply' : 'Add' })] })] }) }));
6
6
  };
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import type { InternalGroup, UserSearchResult, CanopyGroupId, CanopyUserId } from './types.js';
3
3
  export interface InternalGroupsTabProps {
4
4
  groups: InternalGroup[];
5
+ unsavedGroupIds: ReadonlySet<CanopyGroupId>;
5
6
  canEdit: boolean;
6
7
  onCreateGroup: () => void;
7
8
  onEditGroup: (group: InternalGroup) => void;
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Button, Group, Paper, ScrollArea, Stack, Text } from '@mantine/core';
4
4
  import { IconPlus, IconUsers } from '@tabler/icons-react';
5
5
  import { GroupCard } from './GroupCard.js';
6
- export const InternalGroupsTab = ({ groups, canEdit, onCreateGroup, onEditGroup, onDeleteGroup, onAddMember, onRemoveMember, onGetUserMetadata, activeSearchGroupId, searchQuery, searchResults, isSearching, searchError, onSearchQueryChange, onShowSearch, onHideSearch, canSearch, }) => {
6
+ export const InternalGroupsTab = ({ groups, unsavedGroupIds, canEdit, onCreateGroup, onEditGroup, onDeleteGroup, onAddMember, onRemoveMember, onGetUserMetadata, activeSearchGroupId, searchQuery, searchResults, isSearching, searchError, onSearchQueryChange, onShowSearch, onHideSearch, canSearch, }) => {
7
7
  if (!canEdit) {
8
8
  return (_jsx("div", { style: {
9
9
  flex: 1,
@@ -11,5 +11,5 @@ export const InternalGroupsTab = ({ groups, canEdit, onCreateGroup, onEditGroup,
11
11
  paddingTop: 'var(--mantine-spacing-md)',
12
12
  }, children: groups.length === 0 ? (_jsxs(Paper, { withBorder: true, p: "md", style: { textAlign: 'center' }, children: [_jsx(IconUsers, { size: 32, color: "gray", style: { margin: '0 auto', marginBottom: 8 } }), _jsx(Text, { size: "sm", c: "dimmed", children: "No internal groups" })] })) : (_jsx(Stack, { gap: "xs", pb: "md", children: groups.map((group) => (_jsx(Paper, { withBorder: true, p: "sm", children: _jsxs(Group, { justify: "space-between", children: [_jsxs("div", { children: [_jsx(Text, { size: "sm", fw: 500, children: group.name }), group.description && (_jsx(Text, { size: "xs", c: "dimmed", children: group.description }))] }), _jsxs(Text, { size: "sm", variant: "light", children: [group.members?.length || 0, " members"] })] }) }, group.id))) })) }));
13
13
  }
14
- return (_jsx(ScrollArea, { style: { height: '100%' }, pt: "md", children: _jsxs(Stack, { gap: "sm", pb: "md", children: [_jsxs(Group, { justify: "space-between", mb: "xs", children: [_jsx(Text, { size: "sm", fw: 500, c: "dimmed", children: "Manage your internal groups" }), _jsx(Button, { size: "xs", variant: "light", leftSection: _jsx(IconPlus, { size: 14 }), onClick: onCreateGroup, children: "Create Group" })] }), groups.length === 0 ? (_jsxs(Paper, { withBorder: true, p: "md", style: { textAlign: 'center' }, children: [_jsx(IconUsers, { size: 32, color: "gray", style: { margin: '0 auto', marginBottom: 8 } }), _jsx(Text, { size: "sm", c: "dimmed", children: "No internal groups yet. Create one to get started." })] })) : (groups.map((group) => (_jsx(GroupCard, { group: group, onEdit: onEditGroup, onDelete: onDeleteGroup, onAddMember: onAddMember, onRemoveMember: onRemoveMember, onGetUserMetadata: onGetUserMetadata, showUserSearch: activeSearchGroupId === group.id, searchQuery: searchQuery, searchResults: searchResults, isSearching: isSearching, searchError: searchError, onSearchQueryChange: onSearchQueryChange, onShowSearch: () => onShowSearch(group.id), onHideSearch: onHideSearch, canSearch: canSearch }, group.id))))] }) }));
14
+ return (_jsx(ScrollArea, { style: { height: '100%' }, pt: "md", children: _jsxs(Stack, { gap: "sm", pb: "md", children: [_jsxs(Group, { justify: "space-between", mb: "xs", children: [_jsx(Text, { size: "sm", fw: 500, c: "dimmed", children: "Manage your internal groups" }), _jsx(Button, { size: "xs", variant: "light", leftSection: _jsx(IconPlus, { size: 14 }), onClick: onCreateGroup, children: "Create Group" })] }), groups.length === 0 ? (_jsxs(Paper, { withBorder: true, p: "md", style: { textAlign: 'center' }, children: [_jsx(IconUsers, { size: 32, color: "gray", style: { margin: '0 auto', marginBottom: 8 } }), _jsx(Text, { size: "sm", c: "dimmed", children: "No internal groups yet. Create one to get started." })] })) : (groups.map((group) => (_jsx(GroupCard, { group: group, isUnsaved: unsavedGroupIds.has(group.id), onEdit: onEditGroup, onDelete: onDeleteGroup, onAddMember: onAddMember, onRemoveMember: onRemoveMember, onGetUserMetadata: onGetUserMetadata, showUserSearch: activeSearchGroupId === group.id, searchQuery: searchQuery, searchResults: searchResults, isSearching: isSearching, searchError: searchError, onSearchQueryChange: onSearchQueryChange, onShowSearch: () => onShowSearch(group.id), onHideSearch: onHideSearch, canSearch: canSearch }, group.id))))] }) }));
15
15
  };
@@ -5,6 +5,8 @@ export interface UseGroupStateOptions {
5
5
  }
6
6
  export interface UseGroupStateResult {
7
7
  groups: InternalGroup[];
8
+ /** Listed groups that are new or differ from the last loaded or saved state. */
9
+ unsavedGroupIds: ReadonlySet<CanopyGroupId>;
8
10
  isDirty: boolean;
9
11
  isSaving: boolean;
10
12
  error: string | null;
@@ -1,6 +1,10 @@
1
- import { useState, useEffect, useCallback } from 'react';
1
+ import { useState, useEffect, useCallback, useMemo } from 'react';
2
2
  const TEMP_GROUP_ID_PREFIX = 'temp-';
3
3
  const isTempGroupId = (id) => id.startsWith(TEMP_GROUP_ID_PREFIX);
4
+ const sameMembers = (a = [], b = []) => a.length === b.length && [...a].sort().join('\n') === [...b].sort().join('\n');
5
+ const sameGroup = (a, b) => a.name === b.name &&
6
+ (a.description ?? '') === (b.description ?? '') &&
7
+ sameMembers(a.members, b.members);
4
8
  export function useGroupState({ initialGroups, onSave, }) {
5
9
  const [groups, setGroups] = useState(initialGroups);
6
10
  const [isDirty, setIsDirty] = useState(false);
@@ -10,6 +14,18 @@ export function useGroupState({ initialGroups, onSave, }) {
10
14
  setGroups(initialGroups);
11
15
  setIsDirty(false);
12
16
  }, [initialGroups]);
17
+ // Only while dirty: after a save whose reload failed, initialGroups is stale.
18
+ const unsavedGroupIds = useMemo(() => {
19
+ if (!isDirty)
20
+ return new Set();
21
+ const initialById = new Map(initialGroups.map((g) => [g.id, g]));
22
+ return new Set(groups
23
+ .filter((g) => {
24
+ const initial = initialById.get(g.id);
25
+ return !initial || !sameGroup(g, initial);
26
+ })
27
+ .map((g) => g.id));
28
+ }, [groups, initialGroups, isDirty]);
13
29
  const createGroup = useCallback((name, description) => {
14
30
  const newGroup = {
15
31
  id: `${TEMP_GROUP_ID_PREFIX}${crypto.randomUUID()}`,
@@ -79,6 +95,7 @@ export function useGroupState({ initialGroups, onSave, }) {
79
95
  }, [initialGroups]);
80
96
  return {
81
97
  groups,
98
+ unsavedGroupIds,
82
99
  isDirty,
83
100
  isSaving,
84
101
  error,
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState, useCallback } from 'react';
4
- import { Alert, Button, Group, Loader, Stack, Tabs, Text } from '@mantine/core';
4
+ import { Alert, Group, Loader, Stack, Tabs, Text } from '@mantine/core';
5
5
  import { IconAlertCircle } from '@tabler/icons-react';
6
6
  import { useGroupState } from './hooks/useGroupState.js';
7
7
  import { useUserSearch } from './hooks/useUserSearch.js';
@@ -9,8 +9,13 @@ import { useExternalGroupSearch } from './hooks/useExternalGroupSearch.js';
9
9
  import { InternalGroupsTab } from './InternalGroupsTab.js';
10
10
  import { ExternalGroupsTab } from './ExternalGroupsTab.js';
11
11
  import { GroupForm } from './GroupForm.js';
12
- export const GroupManager = ({ internalGroups: initialInternalGroups, loading = false, canEdit, onSave, onSearchUsers, onGetUserMetadata, onSearchExternalGroups, onClose: _, }) => {
13
- const { groups, isDirty, isSaving, error, setError, createGroup, updateGroup, deleteGroup, addMember, removeMember, save, discard, } = useGroupState({
12
+ import { StagedChangesDrawer, useOpeningKey } from '../components/StagedChangesDrawer.js';
13
+ export const GroupManager = (props) => {
14
+ const { key, onExited } = useOpeningKey();
15
+ return _jsx(GroupManagerDrawer, { ...props, onExited: onExited }, key);
16
+ };
17
+ const GroupManagerDrawer = ({ opened, onClose, onExited, internalGroups: initialInternalGroups, loading = false, canEdit, onSave, onSearchUsers, onGetUserMetadata, onSearchExternalGroups, }) => {
18
+ const { groups, unsavedGroupIds, isDirty, isSaving, error, setError, createGroup, updateGroup, deleteGroup, addMember, removeMember, save, discard, } = useGroupState({
14
19
  initialGroups: initialInternalGroups,
15
20
  onSave,
16
21
  });
@@ -53,7 +58,7 @@ export const GroupManager = ({ internalGroups: initialInternalGroups, loading =
53
58
  addMember(groupId, userId);
54
59
  userSearch.hideSearch();
55
60
  }, [addMember, userSearch]);
56
- return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, children: [!canEdit && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "yellow", mb: "sm", title: "Read-only", children: "You need admin access to manage groups." })), error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", mb: "sm", title: "Error", withCloseButton: true, onClose: () => setError(null), children: error })), loading ? (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "md" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading groups..." })] })) : (_jsxs(Tabs, { defaultValue: "internal", style: { flex: 1, display: 'flex', flexDirection: 'column' }, children: [_jsxs(Tabs.List, { children: [_jsx(Tabs.Tab, { value: "internal", children: "Internal Groups" }), _jsx(Tabs.Tab, { value: "external", children: "External Groups" })] }), _jsx(Tabs.Panel, { value: "internal", style: { flex: 1, overflow: 'auto' }, children: _jsx(InternalGroupsTab, { groups: groups, canEdit: canEdit, onCreateGroup: handleCreateGroup, onEditGroup: handleEditGroup, onDeleteGroup: deleteGroup, onAddMember: handleAddMember, onRemoveMember: removeMember, onGetUserMetadata: onGetUserMetadata, activeSearchGroupId: userSearch.activeGroupId, searchQuery: userSearch.searchQuery, searchResults: userSearch.searchResults, isSearching: userSearch.isSearching, searchError: userSearch.searchError, onSearchQueryChange: userSearch.setSearchQuery, onShowSearch: userSearch.showSearch, onHideSearch: userSearch.hideSearch, canSearch: !!onSearchUsers }) }), _jsx(Tabs.Panel, { value: "external", style: { flex: 1, overflow: 'auto' }, children: _jsx(ExternalGroupsTab, { canEdit: canEdit, searchQuery: externalGroupSearch.searchQuery, searchResults: externalGroupSearch.searchResults, isSearching: externalGroupSearch.isSearching, searchError: externalGroupSearch.searchError, onSearchQueryChange: externalGroupSearch.setSearchQuery, canSearch: !!onSearchExternalGroups }) })] })), canEdit && isDirty && (_jsxs(Group, { justify: "flex-end", py: "sm", gap: "sm", style: { borderTop: '1px solid var(--mantine-color-gray-3)' }, children: [_jsx(Button, { variant: "subtle", color: "neutral", onClick: discard, disabled: isSaving, children: "Discard Changes" }), _jsx(Button, { onClick: save, loading: isSaving, disabled: isSaving, children: "Save Groups" })] })), _jsx(GroupForm, { isOpen: isModalOpen, editingGroup: editingGroup, formData: formData, onFormChange: handleFormChange, onSave: handleSaveModal, onClose: () => setIsModalOpen(false) })] }));
61
+ return (_jsx(StagedChangesDrawer, { opened: opened, onClose: onClose, title: "Groups", description: "Manage groups and organizations", size: 600, isDirty: isDirty, isSaving: isSaving, canEdit: canEdit, saveLabel: "Save Groups", onSave: save, onDiscard: discard, childModalOpen: isModalOpen, onExited: onExited, children: _jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, children: [!canEdit && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "yellow", mb: "sm", title: "Read-only", children: "You need admin access to manage groups." })), error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", mb: "sm", title: "Error", withCloseButton: true, onClose: () => setError(null), children: error })), loading ? (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "md" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading groups..." })] })) : (_jsxs(Tabs, { defaultValue: "internal", style: { flex: 1, display: 'flex', flexDirection: 'column' }, children: [_jsxs(Tabs.List, { children: [_jsx(Tabs.Tab, { value: "internal", children: "Internal Groups" }), _jsx(Tabs.Tab, { value: "external", children: "External Groups" })] }), _jsx(Tabs.Panel, { value: "internal", style: { flex: 1, overflow: 'auto' }, children: _jsx(InternalGroupsTab, { groups: groups, unsavedGroupIds: unsavedGroupIds, canEdit: canEdit, onCreateGroup: handleCreateGroup, onEditGroup: handleEditGroup, onDeleteGroup: deleteGroup, onAddMember: handleAddMember, onRemoveMember: removeMember, onGetUserMetadata: onGetUserMetadata, activeSearchGroupId: userSearch.activeGroupId, searchQuery: userSearch.searchQuery, searchResults: userSearch.searchResults, isSearching: userSearch.isSearching, searchError: userSearch.searchError, onSearchQueryChange: userSearch.setSearchQuery, onShowSearch: userSearch.showSearch, onHideSearch: userSearch.hideSearch, canSearch: !!onSearchUsers }) }), _jsx(Tabs.Panel, { value: "external", style: { flex: 1, overflow: 'auto' }, children: _jsx(ExternalGroupsTab, { canEdit: canEdit, searchQuery: externalGroupSearch.searchQuery, searchResults: externalGroupSearch.searchResults, isSearching: externalGroupSearch.isSearching, searchError: externalGroupSearch.searchError, onSearchQueryChange: externalGroupSearch.setSearchQuery, canSearch: !!onSearchExternalGroups }) })] })), _jsx(GroupForm, { isOpen: isModalOpen, editingGroup: editingGroup, formData: formData, onFormChange: handleFormChange, onSave: handleSaveModal, onClose: () => setIsModalOpen(false) })] }) }));
57
62
  };
58
63
  export { useGroupState } from './hooks/useGroupState.js';
59
64
  export { useUserSearch } from './hooks/useUserSearch.js';
@@ -5,6 +5,9 @@ import type { ExternalGroup } from '../../api/groups.js';
5
5
  export type { UserSearchResult, InternalGroup, ExternalGroup };
6
6
  export type { CanopyGroupId, CanopyUserId };
7
7
  export interface GroupManagerProps {
8
+ opened: boolean;
9
+ /** Closes the drawer; while there are unsaved changes it runs only after the user confirms discarding them. */
10
+ onClose: () => void;
8
11
  internalGroups: InternalGroup[];
9
12
  loading?: boolean;
10
13
  canEdit: boolean;
@@ -12,7 +15,6 @@ export interface GroupManagerProps {
12
15
  onSearchUsers?: (query: string, limit?: number) => Promise<UserSearchResult[]>;
13
16
  onGetUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
14
17
  onSearchExternalGroups?: (query: string) => Promise<ExternalGroup[]>;
15
- onClose?: () => void;
16
18
  }
17
19
  export interface GroupFormData {
18
20
  name: string;
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { notifications } from '@mantine/notifications';
3
3
  import { useApiClient } from '../context/index.js';
4
4
  /**
@@ -64,13 +64,14 @@ export function usePermissionManager(options) {
64
64
  // an unreadable groups.json rendered as a silently empty picker with no
65
65
  // warning, which is the same failure class listGroupsHandler's deliberate
66
66
  // 500 exists to prevent.
67
- const handleListGroups = async () => {
67
+ // Stable identity: useGroupsAndUsers reloads groups whenever this function changes.
68
+ const handleListGroups = useCallback(async () => {
68
69
  const result = await apiClient.permissions.listGroups();
69
70
  if (!result.ok) {
70
71
  throw new Error(result.error || 'Failed to load groups');
71
72
  }
72
73
  return result.data?.groups ?? [];
73
- };
74
+ }, [apiClient]);
74
75
  useEffect(() => {
75
76
  if (options.isOpen) {
76
77
  loadPermissions();
@@ -7,7 +7,12 @@ import { usePermissionTree } from './hooks/usePermissionTree.js';
7
7
  import { useGroupsAndUsers } from './hooks/useGroupsAndUsers.js';
8
8
  import { PermissionTree } from './PermissionTree.js';
9
9
  import { findTreeNode } from './utils.js';
10
- export const PermissionManager = ({ collections, contentRoot = 'content', permissions, canEdit, onSave, onSearchUsers, onGetUserMetadata, onListGroups, onClose: __, loading = false, contentTree, }) => {
10
+ import { StagedChangesDrawer, useOpeningKey } from '../components/StagedChangesDrawer.js';
11
+ export const PermissionManager = (props) => {
12
+ const { key, onExited } = useOpeningKey();
13
+ return _jsx(PermissionManagerDrawer, { ...props, onExited: onExited }, key);
14
+ };
15
+ const PermissionManagerDrawer = ({ opened, onClose, onExited, collections, contentRoot = 'content', permissions, canEdit, onSave, onSearchUsers, onGetUserMetadata, onListGroups, loading = false, contentTree, }) => {
11
16
  const [isSaving, setIsSaving] = useState(false);
12
17
  const [error, setError] = useState(null);
13
18
  const [activeLevel, setActiveLevel] = useState('read');
@@ -18,7 +23,8 @@ export const PermissionManager = ({ collections, contentRoot = 'content', permis
18
23
  contentTree,
19
24
  });
20
25
  const { groupSelectData, filteredGroups, groupLoadError, groupSearchQuery, showGroupSearch, setGroupSearchQuery, setShowGroupSearch, clearGroupLoadError, userSearchResults, isSearchingUsers, userSearchQuery, showUserSearch, userSearchError, setUserSearchQuery, toggleUserSearch, } = useGroupsAndUsers({
21
- onListGroups,
26
+ // This panel stays mounted while its drawer is closed; groups load on each opening.
27
+ onListGroups: opened ? onListGroups : undefined,
22
28
  onSearchUsers,
23
29
  canEdit,
24
30
  });
@@ -102,7 +108,7 @@ export const PermissionManager = ({ collections, contentRoot = 'content', permis
102
108
  allowedUsers: currentTarget.allowedUsers,
103
109
  });
104
110
  }, [annotatedTree, localPermissions, updateNodePermission]);
105
- return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, children: [!canEdit && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "yellow", mb: "sm", title: "Read-only", children: "You need admin access to edit permissions" })), error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", mb: "sm", title: "Error", withCloseButton: true, onClose: () => setError(null), children: error })), groupLoadError && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "orange", mb: "sm", title: "Warning", withCloseButton: true, onClose: clearGroupLoadError, children: groupLoadError })), _jsxs(Group, { gap: "xs", pb: "sm", children: [_jsx(Button, { size: "xs", variant: "subtle", onClick: expandAll, children: "Expand All" }), _jsx(Button, { size: "xs", variant: "subtle", onClick: collapseAll, children: "Collapse All" })] }), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: loading ? (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "md" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading permissions..." })] })) : (_jsx(PermissionTree, { node: annotatedTree, expandedNodes: expandedNodes, selectedNode: selectedNode, canEdit: canEdit, groups: groupSelectData, activeLevel: activeLevel, userSearchResults: userSearchResults, isSearchingUsers: isSearchingUsers, showUserSearch: showUserSearch, userSearchQuery: userSearchQuery, userSearchError: userSearchError, showGroupSearch: showGroupSearch, groupSearchQuery: groupSearchQuery, filteredGroups: filteredGroups, onToggle: toggleNode, onSelect: selectNode, onSetActiveLevel: setActiveLevel, onUpdatePermission: updateNodePermission, onSearchUsers: setUserSearchQuery, onGetUserMetadata: onGetUserMetadata, onToggleUserSearch: toggleUserSearch, onAddUser: handleAddUser, onRemoveUser: handleRemoveUser, onSearchGroups: setGroupSearchQuery, onToggleGroupSearch: setShowGroupSearch, onAddGroup: handleAddGroup, onRemoveGroup: handleRemoveGroup })) }), canEdit && isDirty && (_jsxs(Group, { justify: "flex-end", py: "sm", gap: "sm", style: { borderTop: '1px solid var(--mantine-color-gray-3)' }, children: [_jsx(Button, { variant: "subtle", color: "neutral", onClick: handleDiscard, disabled: isSaving, children: "Discard Changes" }), _jsx(Button, { onClick: handleSave, loading: isSaving, disabled: isSaving, children: "Save Permissions" })] }))] }));
111
+ return (_jsx(StagedChangesDrawer, { opened: opened, onClose: onClose, title: "Permissions", description: "Manage content access by path (read, edit, review)", size: 700, isDirty: isDirty, isSaving: isSaving, canEdit: canEdit, saveLabel: "Save Permissions", onSave: handleSave, onDiscard: handleDiscard, onExited: onExited, children: _jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, children: [!canEdit && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "yellow", mb: "sm", title: "Read-only", children: "You need admin access to edit permissions" })), error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", mb: "sm", title: "Error", withCloseButton: true, onClose: () => setError(null), children: error })), groupLoadError && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "orange", mb: "sm", title: "Warning", withCloseButton: true, onClose: clearGroupLoadError, children: groupLoadError })), _jsxs(Group, { gap: "xs", pb: "sm", children: [_jsx(Button, { size: "xs", variant: "subtle", onClick: expandAll, children: "Expand All" }), _jsx(Button, { size: "xs", variant: "subtle", onClick: collapseAll, children: "Collapse All" })] }), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: loading ? (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "md" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading permissions..." })] })) : (_jsx(PermissionTree, { node: annotatedTree, expandedNodes: expandedNodes, selectedNode: selectedNode, canEdit: canEdit, groups: groupSelectData, activeLevel: activeLevel, userSearchResults: userSearchResults, isSearchingUsers: isSearchingUsers, showUserSearch: showUserSearch, userSearchQuery: userSearchQuery, userSearchError: userSearchError, showGroupSearch: showGroupSearch, groupSearchQuery: groupSearchQuery, filteredGroups: filteredGroups, onToggle: toggleNode, onSelect: selectNode, onSetActiveLevel: setActiveLevel, onUpdatePermission: updateNodePermission, onSearchUsers: setUserSearchQuery, onGetUserMetadata: onGetUserMetadata, onToggleUserSearch: toggleUserSearch, onAddUser: handleAddUser, onRemoveUser: handleRemoveUser, onSearchGroups: setGroupSearchQuery, onToggleGroupSearch: setShowGroupSearch, onAddGroup: handleAddGroup, onRemoveGroup: handleRemoveGroup })) })] }) }));
106
112
  };
107
113
  export { usePermissionTree } from './hooks/usePermissionTree.js';
108
114
  export { useGroupsAndUsers } from './hooks/useGroupsAndUsers.js';
@@ -2,6 +2,9 @@ import type { PathPermission, PermissionLevel, PermissionTarget } from '../../co
2
2
  import type { UserSearchResult, GroupSource, PermissionGroupOption } from '../../auth/types.js';
3
3
  import type { EditorCollection } from '../Editor.js';
4
4
  export interface PermissionManagerProps {
5
+ opened: boolean;
6
+ /** Closes the drawer; while there are unsaved changes it runs only after the user confirms discarding them. */
7
+ onClose: () => void;
5
8
  /** Collections from API */
6
9
  collections?: EditorCollection[];
7
10
  /** Content root path (default: 'content') */
@@ -14,7 +17,6 @@ export interface PermissionManagerProps {
14
17
  onGetUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
15
18
  /** Handler to list groups (internal + external, tagged by `source`) */
16
19
  onListGroups?: () => Promise<PermissionGroupOption[]>;
17
- onClose?: () => void;
18
20
  loading?: boolean;
19
21
  /** Optional: actual filesystem content tree (for entries not in schema) */
20
22
  contentTree?: ContentNode;
@@ -49,6 +49,16 @@ export type BareRemoteRepackResult = {
49
49
  * it packed and their emptied fan-out directories. `git` as for `ensureRemoteGitConfig`.
50
50
  */
51
51
  export declare function repackBareRemoteIfNeeded(gitDir: string, git?: SimpleGit): Promise<BareRemoteRepackResult>;
52
+ /**
53
+ * simple-git reads a git that exited by signal (exit code null, often with no stderr) as success,
54
+ * so a clone or checkout the OOM killer stopped would pass for complete. Given as `errors` to a
55
+ * GitManager's own instance, to `cloneRepo`'s, and to the worker's ref probes of an existing
56
+ * remote.git (worker/cms-worker.ts); its static helpers and the bare-remote functions do not pass
57
+ * it yet (.claude/future-tasks/simple-git-signal-exit-reads-as-success.md).
58
+ */
59
+ export declare function failOnSignalExit(error: Buffer | Error | undefined, result: {
60
+ exitCode: number;
61
+ }): Buffer | Error | undefined;
52
62
  /**
53
63
  * Config every workspace clone writes into its own `.git/config`: the managed
54
64
  * marker that ensureRemote's guard checks, and a fallback author for internal
@@ -77,8 +77,8 @@ export function gitChildEnv(overrides) {
77
77
  * classifier into a no-op.
78
78
  *
79
79
  * `GIT_SSH_COMMAND` stays out even though it is a "network" variable:
80
- * simple-git hard-blocks it (`allowUnsafeSshCommand`), and `buildGitHubUrl()`
81
- * produces an `https://` URL, so the worker never reaches GitHub over SSH.
80
+ * simple-git hard-blocks it (`allowUnsafeSshCommand`), and the worker's GitHub
81
+ * gateway builds an `https://` URL, so the worker never reaches GitHub over SSH.
82
82
  */
83
83
  const GIT_NETWORK_ENV_PASSTHROUGH = /^((HTTPS?|ALL)_PROXY|(https?|all)_proxy|NO_PROXY|no_proxy|GIT_SSL_(CAINFO|CAPATH|NO_VERIFY|VERSION)|CURL_CA_BUNDLE|SSL_CERT_(FILE|DIR)|REQUESTS_CA_BUNDLE|NODE_EXTRA_CA_CERTS)$/;
84
84
  export function gitNetworkChildEnv() {
@@ -179,10 +179,11 @@ export async function repackBareRemoteIfNeeded(gitDir, git = simpleGit().env(git
179
179
  /**
180
180
  * simple-git reads a git that exited by signal (exit code null, often with no stderr) as success,
181
181
  * so a clone or checkout the OOM killer stopped would pass for complete. Given as `errors` to a
182
- * GitManager's own instance and to `cloneRepo`'s; its static helpers and the bare-remote functions
183
- * do not pass it yet (.claude/future-tasks/simple-git-signal-exit-reads-as-success.md).
182
+ * GitManager's own instance, to `cloneRepo`'s, and to the worker's ref probes of an existing
183
+ * remote.git (worker/cms-worker.ts); its static helpers and the bare-remote functions do not pass
184
+ * it yet (.claude/future-tasks/simple-git-signal-exit-reads-as-success.md).
184
185
  */
185
- function failOnSignalExit(error, result) {
186
+ export function failOnSignalExit(error, result) {
186
187
  if (error)
187
188
  return error;
188
189
  if (result.exitCode === null) {
@@ -77,6 +77,12 @@ export function redactCredentials(message) {
77
77
  // literal `://` (leaving the scheme untouched) — a `\w+` scheme prefix backtracks
78
78
  // polynomially on long word-character runs (CodeQL js/polynomial-redos).
79
79
  result = result.replace(/(:\/\/)[^/\s@]+@/g, '$1***@');
80
+ // An `Authorization` header's credential: the worker's GitHub-bound git carries the token as
81
+ // `AUTHORIZATION: basic <base64>` (worker/github-mirror.ts).
82
+ result = result.replace(/(\bauthorization\s*:\s*(?:basic|bearer|token)\s+)[A-Za-z0-9+/=._~-]+/gi, '$1***');
83
+ // That header's base64 of `x-access-token:<token>` wherever it appears on its own. The 15-byte
84
+ // prefix encodes to the same 20 characters whatever the token, with no padding to shift it.
85
+ result = result.replace(/eC1hY2Nlc3MtdG9rZW46[A-Za-z0-9+/]*={0,2}/g, '***');
80
86
  // Bare token shapes, for messages embedding a token outside URL userinfo: GitHub token
81
87
  // prefixes and Bearer values.
82
88
  result = result.replace(/\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{8,}/g, '***');
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.0.68-int.111";
1
+ export declare const CANOPYCMS_VERSION = "0.0.68-int.113";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
2
2
  // tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
3
- export const CANOPYCMS_VERSION = '0.0.68-int.111';
3
+ export const CANOPYCMS_VERSION = '0.0.68-int.113';