portal-mojo 0.2.2 → 0.2.4

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 (82) hide show
  1. package/README.md +3 -0
  2. package/docs/DetailView.md +19 -15
  3. package/docs/ModelTable.md +26 -0
  4. package/docs/README.md +2 -1
  5. package/docs/admin-assistant.md +3 -1
  6. package/docs/admin-credentials.md +7 -0
  7. package/docs/admin-dashboard.md +1 -1
  8. package/docs/admin-dns.md +8 -2
  9. package/docs/admin-incidents.md +26 -3
  10. package/docs/admin-jobs.md +7 -4
  11. package/docs/admin-messaging.md +6 -5
  12. package/docs/admin-modals.md +122 -0
  13. package/docs/admin-network-security.md +4 -4
  14. package/docs/admin-phonehub.md +24 -6
  15. package/docs/admin-push.md +7 -0
  16. package/docs/admin-rules.md +8 -0
  17. package/docs/admin-shortlinks.md +2 -1
  18. package/docs/admin-storage.md +2 -1
  19. package/docs/auth.md +28 -0
  20. package/docs/client.md +12 -0
  21. package/docs/detail-primitives.md +12 -1
  22. package/docs/feedback.md +70 -1
  23. package/docs/forms.md +51 -0
  24. package/docs/loading.md +3 -2
  25. package/docs/popover.md +18 -3
  26. package/package.json +1 -1
  27. package/src/admin/assistant/api.ts +1 -1
  28. package/src/admin/assistant/pages.tsx +71 -17
  29. package/src/admin/assistant/skill-lifecycle.ts +9 -0
  30. package/src/admin/core/index.ts +7 -2
  31. package/src/admin/credentials/group-api-keys.tsx +67 -43
  32. package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
  33. package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
  34. package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
  35. package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
  36. package/src/admin/dns/setup.ts +1 -1
  37. package/src/admin/domains/observability.ts +1 -1
  38. package/src/admin/identity/members/MemberDetail.tsx +48 -3
  39. package/src/admin/identity/users/UserDetail.tsx +36 -20
  40. package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
  41. package/src/admin/identity/users/sections/actions.tsx +20 -9
  42. package/src/admin/incidents/EventDetail.tsx +147 -5
  43. package/src/admin/incidents/IncidentDetail.tsx +181 -8
  44. package/src/admin/incidents/models.ts +1 -1
  45. package/src/admin/jobs/RunnerDetail.tsx +5 -8
  46. package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
  47. package/src/admin/jobs/index.ts +1 -1
  48. package/src/admin/jobs/purge-preview.ts +25 -0
  49. package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
  50. package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
  51. package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
  52. package/src/admin/messaging/MailboxesPage.tsx +3 -3
  53. package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
  54. package/src/admin/messaging/push/PushPage.tsx +20 -8
  55. package/src/admin/monitoring/LogInspector.tsx +3 -19
  56. package/src/admin/network/IPSetDetail.tsx +32 -63
  57. package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
  58. package/src/admin/phonehub/SmsComposer.tsx +97 -0
  59. package/src/admin/phonehub/api.ts +11 -5
  60. package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
  61. package/src/admin/rules/condition-removal.ts +7 -0
  62. package/src/admin/security/geoip/models.ts +5 -0
  63. package/src/admin/security/tickets.tsx +40 -17
  64. package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
  65. package/src/admin/storage/BucketDetail.tsx +28 -9
  66. package/src/admin/storage/FileManagerDetail.tsx +23 -5
  67. package/src/admin/storage/FileView.tsx +28 -16
  68. package/src/admin/storage/FilesPage.tsx +4 -5
  69. package/src/client/auth.ts +10 -1
  70. package/src/client/client.ts +14 -6
  71. package/src/client/mock.ts +90 -6
  72. package/src/client/upload.ts +1 -0
  73. package/src/ui/DetailView.tsx +22 -5
  74. package/src/ui/FormView.tsx +24 -1
  75. package/src/ui/ModelTable.tsx +12 -4
  76. package/src/ui/Popover.tsx +13 -12
  77. package/src/ui/detail/MetadataSection.tsx +46 -10
  78. package/src/ui/form-autosave.ts +134 -23
  79. package/src/ui/guardrail.tsx +125 -0
  80. package/src/ui/index.ts +1 -0
  81. package/src/ui/modal.tsx +8 -4
  82. package/src/ui/toast.tsx +48 -5
@@ -109,6 +109,7 @@ value. They coexist happily — `<DataView data={record} />` for the record,
109
109
  <MetadataSection
110
110
  endpoint="/api/user" id={user.id} metadata={user.metadata}
111
111
  onSaved={(next) => qc.setQueryData<User>(['/api/user', 'one', user.id], (p) => p && { ...p, metadata: next })}
112
+ beforeSave={async (next, prev) => …} // optional owner gate — see below
112
113
  />
113
114
  ```
114
115
 
@@ -133,7 +134,17 @@ Invariants:
133
134
  Object values then display **read-only** — a string editor round-trips
134
135
  them lossily; remove and re-add to change one.
135
136
 
136
- Deletion confirms through `modal.confirm`; success toasts.
137
+ **Removing a top-level key stops on a guardrail** (`confirmGuardrail`), not a
138
+ plain confirm: the POST carries the whole blob, so one write can wipe
139
+ `auth_config` / `geofence` / `redemption_policy`. The stop names every key
140
+ about to leave and how many remain; removing more than one key at once asks
141
+ the operator to type the count. `commit()` computes the removed set from
142
+ `metadata` vs `next` — every write path, not just the trash button, hits it.
143
+
144
+ `beforeSave(next, prev)` runs after that built-in stop and before the POST.
145
+ Resolve `false` to cancel — nothing posts, no error, the editor keeps its
146
+ draft. Owners diff their own protected keys here (a `changedKeys`-style
147
+ compare on the values of `auth_config`, say). Success toasts.
137
148
 
138
149
  ## StackTraceView — JS **and** Python tracebacks
139
150
 
package/docs/feedback.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # feedback — awaitable modals + toasts
2
2
 
3
3
  ```ts
4
- import { modal, formModal, toast, ModalHost, ToastHost } from 'portal-mojo/ui';
4
+ import { modal, formModal, toast, confirmGuardrail, ModalHost, ToastHost } from 'portal-mojo/ui';
5
5
  ```
6
6
 
7
7
  Mount `<ModalHost />` and `<ToastHost />` once in the app shell. Modals are
@@ -24,6 +24,51 @@ Patterns:
24
24
  input; resolve null to cancel the whole thing.
25
25
  - Stacked dialogs are fine (disable-reason form over a detail modal).
26
26
 
27
+ ## confirmGuardrail — the "are you sure, and here is why" stop
28
+
29
+ ```tsx
30
+ if (!(await confirmGuardrail({
31
+ title: 'Disable webhook to hooks.example.com?', // question form, names the target
32
+ effect: <>Deliveries of <b>order.paid</b> stop the moment this saves.</>,
33
+ why: [ // one concrete consequence per bullet
34
+ <>Nothing re-registers them — it stays off until someone turns it back on.</>,
35
+ <>Consumers see silence, not an error.</>,
36
+ ],
37
+ undo: 'Re-enable it here to resume deliveries.', // or say that you can't
38
+ confirmText: 'Disable webhook',
39
+ typeToConfirm: 'DISABLE', // optional: button disabled until typed
40
+ danger: true, // default; false = primary button + warn tint
41
+ }))) return;
42
+ ```
43
+
44
+ Resolves `true` only on the armed confirm button — `false` on Escape,
45
+ backdrop and Cancel. Opens on `modal.open(…, { size: 'sm' })`, so it stacks
46
+ over a `formModal` that is still open. `typeToConfirm` compares trimmed and
47
+ case-insensitively; Enter in that input confirms once it matches.
48
+
49
+ **Which stop.** Three idioms, one rule each:
50
+
51
+ | Idiom | When |
52
+ |---|---|
53
+ | `modal.confirm` | Reversible / low blast radius: a yes-no is enough. |
54
+ | `ArmedButton` | Irreversible, needs no input, inline (row actions) — see idioms.md. |
55
+ | `confirmGuardrail` | Anything that **takes a tenant dark, moves money, or can't be undone** — the operator must read *why* before the button arms. |
56
+
57
+ Copy is written from what the backend does, not adjectives: "every
58
+ integration holding this key gets 401 on its next call", not "this is
59
+ dangerous". Where it is wired in the package: API-key deactivate, webhook
60
+ disable, member `manage_group` / `manage_members` grants (through
61
+ `FormView.beforeSave`), and MetadataSection's removed-key stop.
62
+
63
+ `changedKeys(initial, next, watch)` is the diff helper for config-form saves:
64
+ which of the watched keys changed (null/'' normalised), so a save confirms
65
+ ONLY the fields that break things and passes silently otherwise.
66
+
67
+ Styles: `apps/portal/src/theme/guardrail.css` (`.guardrail`,
68
+ `.guardrail-effect/-why/-why-head/-undo/-type`, `.guardrail-icon`) over
69
+ `--bad/--bad-soft` (danger) and `--warn/--warn-soft` (`danger: false`). The
70
+ package ships no CSS — a consuming app carries that block (README).
71
+
27
72
  ## modal.drawer
28
73
 
29
74
  ```ts
@@ -79,7 +124,31 @@ bottom-right cards, max 5, 3.5s. Conventions:
79
124
  - `response: 'payload'` actions toast the SERVER's payload message
80
125
  (`outcome.body.message`), not client copy.
81
126
 
127
+ `ToastHost` uses a manual native popover above dialog backdrops. Its stable
128
+ portal target follows the most recently opened modal (including nested dialogs
129
+ and drawers), then returns to the page when the last modal closes. Modal-local
130
+ ancestry keeps Undo and progress Cancel usable despite native dialog inertness.
131
+ Moving the host preserves card state and timers and does not focus the toast.
132
+ Mount it once; do not add per-dialog toast hosts or solve this with `z-index`.
133
+ Custom app themes must include the updated toast CSS from
134
+ `apps/portal/src/theme.css`: reset the popover's UA inset/margin/border/padding
135
+ and background, hide closed popovers, and keep only the cards pointer-active.
136
+ Updating the TypeScript package alone does not update a consumer's own theme.
137
+ The showcase's **Toasts inside modal** demo covers errors, Undo, progress,
138
+ and nested opening/closing. `node scripts/verify-toast-modal.mjs` checks the
139
+ mounted lifecycle; native visibility and interaction require browser checks.
140
+
82
141
  ## Pitfall
83
142
 
84
143
  An open `<dialog>` pauses ModelTable's autoRefresh by design — don't build
85
144
  alternative modal systems that bypass `<dialog>` or that interlock breaks.
145
+
146
+ ## Admin focused dialogs
147
+
148
+ Use `modal-pad`, `modal-title`, `modal-message` and `modal-actions`; Cancel
149
+ precedes the primary action. Keep long form bodies scrollable and actions
150
+ reachable in short/narrow viewports. A pending mutation needs both disabled
151
+ buttons and a live `canDismiss` gate for Escape/backdrop. Content records use
152
+ DetailView header actions; never teach routine record deletion in a danger rail.
153
+ See [Admin modal policy](admin-modals.md) for supported lifecycle semantics,
154
+ permission checks and the complete retained-removal contract.
package/docs/forms.md CHANGED
@@ -105,10 +105,51 @@ if (!data) return; // cancelled → null
105
105
  savedFlashMs={1500} // saved-check duration (default 1500)
106
106
  onSaved={({ changes, fields, row }) => …}
107
107
  onSaveError={({ changes, fields, error }) => …}
108
+ beforeSave={({ changes, names, row }) => …} // once per batch, BEFORE the POST (below)
108
109
  onPendingChange={(uploading) => setDetailDismissLocked(uploading)}
109
110
  />
110
111
  ```
111
112
 
113
+ ### `beforeSave` — the pre-save gate
114
+
115
+ ```tsx
116
+ beforeSave={({ changes, names, row }) => {
117
+ // changes: the wire body (dotted names expanded: {permissions: {manage_group: true}})
118
+ // names: the flat field names in the batch ('permissions.manage_group')
119
+ const perms = changes.permissions as Record<string, unknown> | undefined;
120
+ if (perms?.manage_group !== true) return true; // proceed
121
+ return confirmGuardrail({ title: `Grant Manage Group to ${row.email}?`, … }); // false → drop
122
+ }}
123
+ ```
124
+
125
+ Runs once per batch with the body about to POST. Three results:
126
+
127
+ | Return | Effect |
128
+ |---|---|
129
+ | `false` | **Drop the batch.** Its fields revert to the server snapshot and go idle — no error, no toast. A guardrail the operator declined is not a failure. |
130
+ | an object | **Replace the body.** The object POSTs instead of `changes` (`onSaved.changes` reports what was sent). |
131
+ | `true` / `undefined` | Proceed unchanged. |
132
+
133
+ While it awaits, the batch is **in flight**: the 300ms window cannot re-fire
134
+ and a commit made meanwhile joins the **next** batch (which gets its own
135
+ `beforeSave`). A `false` reverts only the names this batch carried — a field
136
+ re-committed during the await keeps its new value for the next batch. A hook
137
+ that throws or rejects is a bug, not a veto: it surfaces exactly like a
138
+ failed save (revert + pinned error + `onSaveError` toast). Without the prop
139
+ the path is the pre-existing one, unchanged.
140
+
141
+ Each batch captures its save target and result callbacks before confirmation.
142
+ Rerendering with another record while the gate awaits cannot redirect that
143
+ approved write or its callbacks to the new record.
144
+ FormView also isolates its mutation observer and draft state by model and row
145
+ ID. Switching either starts a fresh form; a refetch of the same row preserves
146
+ pending edits. Pass a stable model definition, as returned by `defineModel`.
147
+
148
+ Use it for the fields where a typo takes a tenant dark and there is no save
149
+ button to hesitate over: identity fields (`uuid`, `auth_domain`), escalating
150
+ grants (MemberDetail guards `manage_group` / `manage_members` this way). The
151
+ contract is pinned by `npm run verify:form-autosave`.
152
+
112
153
  ### The autosave lifecycle (the machine: `useFormAutosave`)
113
154
 
114
155
  1. **Commit, never keystroke.** Text/textarea update the draft per keystroke
@@ -433,3 +474,13 @@ orchestrator files follow-ups.
433
474
  | `combo` / `combobox` / `autocomplete` | — | ComboBox | OK (ComboInput feature spec, commit-only) |
434
475
  | `address` | — | AddressField | OK (private session token, details → atomic declared-field patch) |
435
476
  | `tabset` | FormView `tabs` prop | — | OK — tabsets are FORM STRUCTURE here (registry-of-tabs + permissions), not a field type |
477
+
478
+ ## Admin focused dialogs
479
+
480
+ Use `modal-pad`, `modal-title`, `modal-message` and `modal-actions`; Cancel
481
+ precedes the primary action. Keep long form bodies scrollable and actions
482
+ reachable in short/narrow viewports. A pending mutation needs both disabled
483
+ buttons and a live `canDismiss` gate for Escape/backdrop. Content records use
484
+ DetailView header actions; never teach routine record deletion in a danger rail.
485
+ See [Admin modal policy](admin-modals.md) for supported lifecycle semantics,
486
+ permission checks and the complete retained-removal contract.
package/docs/loading.md CHANGED
@@ -238,5 +238,6 @@ clicked away mid-save, they keep where they went.
238
238
  - **The overlay is `dialog[open]`.** ModelTable's `autoRefresh` skips ticks
239
239
  while any `dialog[open]` exists, so auto-refresh politely pauses during a
240
240
  busy operation. That is intended; don't "fix" it.
241
- - **Toasts render under the overlay** (`.toast-host` is z-index 60, the
242
- overlay is in the top layer). Toast *after* closing the handle, not during.
241
+ - **Toasts remain above the overlay.** The single [ToastHost](feedback.md#toast)
242
+ follows the active native dialog, keeping notifications and their actions
243
+ available during a blocking operation.
package/docs/popover.md CHANGED
@@ -117,9 +117,9 @@ baked in 20px padding + 260px min-width for its one tenant, the calendar
117
117
  - **Don't wrap the shell in `overflow: hidden` styling assumptions** —
118
118
  it's in the top layer; nothing ancestral clips or covers it. Anything
119
119
  that must paint ABOVE an open popover has to join the top layer later.
120
- - **Toasts render under open modals** (`.toast-host` is z-index 60, not
121
- top layer). Don't signal from popover content inside a modal via toast;
122
- show it in the modal body.
120
+ - **Use the single ToastHost for modal notifications.** It follows the active
121
+ dialog into the native top layer; see [feedback](feedback.md#toast). Keep
122
+ actionable errors in the modal body too when they need to outlast a toast.
123
123
  - **One value pipeline:** the popover is presentation only. Commit
124
124
  semantics (`change` on select/Enter/blur, never per keystroke) belong
125
125
  to the consuming control.
@@ -128,3 +128,18 @@ baked in 20px padding + 260px min-width for its one tenant, the calendar
128
128
  document state, remember listeners attach a tick later.
129
129
  - Placement flips only bottom→top (per source). If content can be taller
130
130
  than the viewport, cap and scroll it in YOUR content styles.
131
+
132
+ ## Modal ownership
133
+
134
+ A popover anchored inside a native modal portals into that dialog in both
135
+ native-Popover and fallback paths. Promoting a body-owned popover to the top
136
+ layer affects painting but does not escape the document's modal inertness; it
137
+ can appear visible while pointer/focus input lands underneath. Keeping the
138
+ portal in the owning dialog preserves menu and picker interaction. The first
139
+ Escape closes the popover; the next may dismiss the parent dialog.
140
+
141
+ Vertical placement clamps to the viewport when neither side fits. DetailView
142
+ menu content owns its height limit and scrolling; the general popover host
143
+ keeps overflow available for nested calendar/picker content. Dialog layout
144
+ cleanup restores focus while the native dialog is still connected. Header
145
+ menu selections focus their stable trigger before launching nested dialogs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,7 +1,7 @@
1
+ import type { FileReference } from '../../client/record-feed';
1
2
  import { mojoCall, mojoDelete, mojoList, type Params } from '../../client/runtime';
2
3
  import { projectConversation, projectConversationSummary, projectReply, projectSkill } from './data';
3
4
  import type { AssistantConversation, AssistantConversationSummary, AssistantReply, AssistantSkill } from './types';
4
- import type { FileReference } from '../../client/record-feed';
5
5
 
6
6
  const malformed = (label: string): never => { throw new Error(`${label} returned an invalid response`); };
7
7
  export async function sendAssistantMessage(message: string, conversationId?: number, attachments: readonly FileReference[] = []): Promise<AssistantReply> { const ids = attachments.map((file) => file.id); if (ids.length > 5 || new Set(ids).size !== ids.length || ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) throw new Error('Assistant attachments must be 1–5 unique completed File references'); const body = await mojoCall('/api/assistant', { method: 'POST', body: { message, ...(conversationId != null ? { conversation_id: conversationId } : {}), ...(ids.length ? { attachments: ids } : {}) } }); return projectReply(body.data) ?? malformed('Assistant'); }
@@ -1,12 +1,15 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import type { MemberLike } from '../../client/me';
1
3
  import { useCallback, useEffect, useRef, useState } from 'react';
2
- import { mojoList, useCan, useMe, useRealtime, useRealtimeStatus } from '../../client/runtime';
3
- import { Badge, fmt, modal, toast } from '../../ui';
4
- import { AssistantFeed } from './AssistantFeed';
5
- import { deleteAssistantConversation, deleteAssistantMemory, deleteAssistantSkill, getAssistantConversation, getAssistantMemory, getAssistantSkill, listAssistantConversations, listAssistantSkills, saveAssistantMemory, sendAssistantMessage } from './api';
6
- import type { AssistantConversation, AssistantConversationSummary, AssistantSkill } from './types';
7
4
  import type { FileReference } from '../../client/record-feed';
5
+ import { memberHasPermission, mojoCall, mojoList, useCan, useMe, useRealtime, useRealtimeStatus } from '../../client/runtime';
6
+ import { Badge, DetailView, FlatRow, fmt, modal } from '../../ui';
7
+ import { AssistantFeed } from './AssistantFeed';
8
8
  import { ASSISTANT_PERMISSIONS } from './AssistantPanel';
9
+ import { deleteAssistantMemory, getAssistantConversation, getAssistantMemory, getAssistantSkill, listAssistantConversations, listAssistantSkills, saveAssistantMemory, sendAssistantMessage } from './api';
10
+ import { setAssistantSkillActive } from './skill-lifecycle';
9
11
  import { AssistantOutcomeUnknownError, chooseAssistantTransport, startAssistantRealtimeTurn, type AssistantRealtimeTurn } from './streaming';
12
+ import type { AssistantConversation, AssistantConversationSummary, AssistantSkill } from './types';
10
13
 
11
14
  function PageState({ loading, error, retry }: { loading: boolean; error: string; retry(): void }) { if (loading) return <div className="panel panel-pad dim">Loading…</div>; if (error) return <div className="panel panel-pad"><div className="form-alert" role="alert">{error}</div><button type="button" className="btn" onClick={retry}>Retry</button></div>; return null; }
12
15
  function Heading({ eyebrow, title, description }: { eyebrow: string; title: string; description: string }) { return <header className="admin-page-heading"><div><div className="eyebrow">{eyebrow}</div><h1>{title}</h1><p>{description}</p></div></header>; }
@@ -49,30 +52,81 @@ function ConversationInspect({ id, close }: { id: number; close(): void }) {
49
52
  throw cause;
50
53
  } finally { stream.current = null; setResponding(false); }
51
54
  };
52
- return <div className="modal-pad assistant-inspect"><header><div><div className="eyebrow">Conversation #{data.id}</div><h2>{data.title}</h2><p className="dim">Owner: {data.user.display_name} · updated {fmt.datetime(data.modified)}</p></div><button className="btn-icon" type="button" onClick={close} aria-label="Close"><i className="bi bi-x-lg" /></button></header><AssistantFeed messages={data.messages} user={{ id: me.id, display_name: me.display_name ?? 'You' }} onSend={send} responding={responding} disabled={!owner} attachmentKey={data.id} /></div>;
55
+ return <DetailView title={data.title} subtitle={`Conversation #${data.id} · ${data.user.display_name} · updated ${fmt.datetime(data.modified)}`} icon="bi-chat-dots" onClose={close} sections={[{ key: 'conversation', label: 'Conversation', icon: 'bi-chat-text', render: () => <AssistantFeed messages={data.messages} user={{ id: me.id, display_name: me.display_name ?? 'You' }} onSend={send} responding={responding} disabled={!owner} attachmentKey={data.id} /> }]} />;
53
56
  }
54
57
 
55
58
  export function ConversationsPage() {
56
- const { data: me } = useMe(); const { can: isAdmin } = useCan('sys.view_admin'); const [rows, setRows] = useState<AssistantConversationSummary[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState('');
59
+ const { data: me } = useMe(); const [rows, setRows] = useState<AssistantConversationSummary[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState('');
57
60
  const load = useCallback(async () => { setLoading(true); setError(''); try { setRows((await listAssistantConversations()).rows); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Conversations unavailable'); } finally { setLoading(false); } }, []);
58
61
  useEffect(() => { void load(); }, [load]);
59
62
  useEffect(() => { const refresh = () => { void load(); }; window.addEventListener('portal-mojo:assistant-list-refresh', refresh); return () => window.removeEventListener('portal-mojo:assistant-list-refresh', refresh); }, [load]);
60
- const remove = async (row: AssistantConversationSummary) => { const confirmed = await modal.confirm({ title: 'Delete conversation?', message: `Delete “${row.title}”? This cannot be undone.`, confirmText: 'Delete', danger: true }); if (!confirmed) return; try { await deleteAssistantConversation(row.id); setRows((current) => current.filter((item) => item.id !== row.id)); toast.success('Conversation deleted'); } catch (cause) { toast.error(cause instanceof Error ? cause.message : 'Delete failed'); } };
61
- return <div><Heading eyebrow="Assistant" title="Conversations" description="Inspect Assistant history. Only the owner may continue a conversation." /><PageState loading={loading} error={error} retry={() => void load()} />{!loading && !error && <div className="panel assistant-list"><table><thead><tr><th>Conversation</th><th>Owner</th><th>Updated</th><th /></tr></thead><tbody>{rows.map((row) => <tr key={row.id}><td><button className="link-button" type="button" onClick={() => void modal.detail((close) => <ConversationInspect id={row.id} close={() => close(null)} />)}>{row.title}</button><small>#{row.id}</small></td><td>{row.user.display_name}{row.user.id === me?.id && <Badge tone="info">You</Badge>}</td><td>{fmt.datetime(row.modified)}</td><td><button className="btn-icon" type="button" aria-label={`Delete ${row.title}`} disabled={!isAdmin && row.user.id !== me?.id} onClick={() => void remove(row)}><i className="bi bi-trash" /></button></td></tr>)}</tbody></table>{rows.length === 0 && <p className="empty">No conversations.</p>}</div>}</div>;
63
+
64
+ return <div><Heading eyebrow="Assistant" title="Conversations" description="Inspect Assistant history. Only the owner may continue a conversation." /><PageState loading={loading} error={error} retry={() => void load()} />{!loading && !error && <div className="panel assistant-list"><table><thead><tr><th>Conversation</th><th>Owner</th><th>Updated</th><th /></tr></thead><tbody>{rows.map((row) => <tr key={row.id}><td><button className="link-button" type="button" onClick={() => void modal.detail((close) => <ConversationInspect id={row.id} close={() => close(null)} />)}>{row.title}</button><small>#{row.id}</small></td><td>{row.user.display_name}{row.user.id === me?.id && <Badge tone="info">You</Badge>}</td><td>{fmt.datetime(row.modified)}</td></tr>)}</tbody></table>{rows.length === 0 && <p className="empty">No conversations.</p>}</div>}</div>;
62
65
  }
63
66
 
64
- function SkillInspect({ id, close }: { id: number; close(): void }) { const [skill, setSkill] = useState<AssistantSkill | null>(null); const [error, setError] = useState(''); useEffect(() => { void getAssistantSkill(id).then(setSkill, (cause) => setError(cause instanceof Error ? cause.message : 'Skill unavailable')); }, [id]); return <div className="modal-pad assistant-skill-detail"><header><h2>{skill?.name ?? 'Skill'}</h2><button className="btn-icon" onClick={close} aria-label="Close"><i className="bi bi-x-lg" /></button></header>{error ? <div className="form-alert">{error}</div> : !skill ? <p className="dim">Loading…</p> : <><p>{skill.description || 'No description.'}</p><dl><div><dt>Tier</dt><dd>{skill.tier}</dd></div><div><dt>Active</dt><dd>{skill.is_active ? 'Yes' : 'No'}</dd></div><div><dt>Auto execute</dt><dd>{skill.auto_execute ? 'Yes' : 'No'}</dd></div></dl><h3>Triggers</h3>{skill.triggers.length ? <ul>{skill.triggers.map((trigger) => <li key={trigger}>{trigger}</li>)}</ul> : <p className="dim">No triggers.</p>}<h3>Steps</h3>{skill.steps.length ? <ol>{skill.steps.map((step, index) => <li key={index}><code>{JSON.stringify(step)}</code></li>)}</ol> : <p className="dim">No steps.</p>}</>}</div>; }
67
+ function SkillInspect({ id, close }: { id: number; close(): void }) {
68
+ const [skill, setSkill] = useState<AssistantSkill | null>(null);
69
+ const [error, setError] = useState(''); const [busy, setBusy] = useState(false);
70
+ const canManage = useCan('sys.view_admin').can;
71
+ useEffect(() => { void getAssistantSkill(id).then(setSkill, cause => setError(cause instanceof Error ? cause.message : 'Skill unavailable')); }, [id]);
72
+ const toggle = async (next: boolean) => { if (!canManage || busy) return; setBusy(true); setError(''); try { setSkill(await setAssistantSkillActive(id, next)); } catch (cause) { setError(cause instanceof Error ? cause.message : 'State change failed'); } finally { setBusy(false); } };
73
+ return <DetailView title={skill?.name ?? 'Skill'} subtitle={skill?.description || `Skill #${id}`} icon="bi-stars" onClose={close}
74
+ active={canManage && skill ? { value: skill.is_active, disabled: busy, onChange: next => void toggle(next) } : undefined}
75
+ contextMenu={canManage && skill ? [{ label: skill.is_active ? 'Deactivate' : 'Reactivate', disabled: busy, onSelect: () => void toggle(!skill.is_active) }] : []}
76
+ sections={[{ key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section">{error && <div className="form-alert">{error}</div>}{skill ? <><FlatRow label="Tier">{skill.tier}</FlatRow><FlatRow label="Active">{skill.is_active ? 'Yes' : 'No'}</FlatRow><FlatRow label="Auto execute">{skill.auto_execute ? 'Yes' : 'No'}</FlatRow><h3>Triggers</h3><ul>{skill.triggers.map(trigger => <li key={trigger}>{trigger}</li>)}</ul><h3>Steps</h3><ol>{skill.steps.map((step, index) => <li key={index}><code>{JSON.stringify(step)}</code></li>)}</ol></> : <p>Loading…</p>}</div> }]} />;
77
+ }
65
78
 
66
- export function SkillsPage() { const [rows, setRows] = useState<AssistantSkill[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const load = useCallback(async () => { setLoading(true); setError(''); try { setRows((await listAssistantSkills()).rows); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Skills unavailable'); } finally { setLoading(false); } }, []); useEffect(() => { void load(); }, [load]); const remove = async (row: AssistantSkill) => { const confirmed = await modal.confirm({ title: 'Delete skill?', message: `Delete “${row.name}”?`, confirmText: 'Delete', danger: true }); if (!confirmed) return; try { await deleteAssistantSkill(row.id); setRows((current) => current.filter((item) => item.id !== row.id)); toast.success('Skill deleted'); } catch (cause) { toast.error(cause instanceof Error ? cause.message : 'Delete failed'); } }; return <div><Heading eyebrow="Assistant" title="Skills" description="Inspect the bounded skill catalog exposed by django-mojo." /><PageState loading={loading} error={error} retry={() => void load()} />{!loading && !error && <div className="panel assistant-list"><table><thead><tr><th>Skill</th><th>Tier</th><th>Status</th><th /></tr></thead><tbody>{rows.map((row) => <tr key={row.id}><td><button className="link-button" onClick={() => void modal.detail((close) => <SkillInspect id={row.id} close={() => close(null)} />)}>{row.name}</button><small>{row.description}</small></td><td>{row.tier}</td><td><Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge></td><td><button className="btn-icon" aria-label={`Delete ${row.name}`} onClick={() => void remove(row)}><i className="bi bi-trash" /></button></td></tr>)}</tbody></table>{rows.length === 0 && <p className="empty">No skills.</p>}</div>}</div>; }
79
+ export function SkillsPage() {
80
+ const [rows, setRows] = useState<AssistantSkill[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState('');
81
+ const load = useCallback(async () => { setLoading(true); setError(''); try { setRows((await listAssistantSkills()).rows); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Skills unavailable'); } finally { setLoading(false); } }, []);
82
+ useEffect(() => { void load(); }, [load]);
83
+ return <div><Heading eyebrow="Assistant" title="Skills" description="Inspect and manage the skill catalog without removing its history." /><PageState loading={loading} error={error} retry={() => void load()} />{!loading && !error && <div className="panel assistant-list"><table><thead><tr><th>Skill</th><th>Tier</th><th>Status</th></tr></thead><tbody>{rows.map(row => <tr key={row.id}><td><button className="link-button" onClick={() => void modal.detail(close => <SkillInspect id={row.id} close={() => { close(null); void load(); }} />)}>{row.name}</button><small>{row.description}</small></td><td>{row.tier}</td><td><Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge></td></tr>)}</tbody></table>{rows.length === 0 && <p className="empty">No skills.</p>}</div>}</div>;
84
+ }
67
85
 
68
86
  interface GroupChoice { id: number; name: string }
69
87
  type MemoryTier = 'global' | 'user' | 'group';
70
88
  export function MemoriesPage() {
71
- const [tier, setTier] = useState<MemoryTier>('user'); const [groups, setGroups] = useState<GroupChoice[]>([]); const [group, setGroup] = useState<number | null>(null); const [entries, setEntries] = useState<Record<string, unknown>>({}); const [key, setKey] = useState(''); const [value, setValue] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState('');
72
- useEffect(() => { void mojoList<GroupChoice>('/api/group', { start: 0, size: 50, sort: 'name' }).then((page) => { setGroups(page.rows.map((row) => ({ id: Number(row.id), name: String(row.name) }))); setGroup((current) => current ?? page.rows[0]?.id ?? null); }, () => setGroups([])); }, []);
73
- const load = useCallback(async () => { if (tier === 'group' && group == null) { setEntries({}); return; } setLoading(true); setError(''); try { setEntries(await getAssistantMemory(tier, tier === 'group' ? group! : undefined)); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Memory unavailable'); } finally { setLoading(false); } }, [tier, group]); useEffect(() => { void load(); }, [load]);
89
+ const { can: systemAllowed, me } = useCan('sys.assistant');
90
+ const [tier, setTier] = useState<MemoryTier>('user');
91
+ const [groups, setGroups] = useState<GroupChoice[]>([]); const [group, setGroup] = useState<number | null>(null);
92
+ const [entries, setEntries] = useState<Record<string, unknown>>({}); const [key, setKey] = useState(''); const [value, setValue] = useState('');
93
+ const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState('');
94
+ const member = useQuery({ queryKey: ['assistant-memory-member', me?.id, group], enabled: systemAllowed && tier === 'group' && group != null && !me?.is_superuser, queryFn: async () => (await mojoCall(`/api/group/${group}/member`)).data as MemberLike });
95
+ const canManage = systemAllowed && (tier !== 'group' || group != null && (Boolean(me?.is_superuser) || memberHasPermission(member.data ?? null, 'assistant')));
96
+ const scopeKey = `${me?.id}:${tier}:${group ?? ''}`;
97
+ const scope = useRef({ key: scopeKey, allowed: canManage, mounted: true }); scope.current.key = scopeKey; scope.current.allowed = canManage;
98
+ const generation = useRef(0); const lock = useRef(false);
99
+ useEffect(() => { scope.current.mounted = true; return () => { scope.current.mounted = false; generation.current++; }; }, []);
100
+ useEffect(() => { if (!systemAllowed) { setGroups([]); return; } let cancelled = false; void mojoList<GroupChoice>('/api/group', { start: 0, size: 50, sort: 'name' }).then(page => { if (!cancelled) setGroups(page.rows.map(row => ({ id: Number(row.id), name: String(row.name) }))); }, () => { if (!cancelled) setGroups([]); }); return () => { cancelled = true; }; }, [systemAllowed]);
101
+ const load = useCallback(async () => {
102
+ const request = ++generation.current; const captured = scopeKey;
103
+ setEntries({}); setError('');
104
+ if (!systemAllowed || tier === 'group' && group == null) { setLoading(false); return; }
105
+ setLoading(true);
106
+ try { const next = await getAssistantMemory(tier, tier === 'group' ? group! : undefined); if (scope.current.mounted && request === generation.current && scope.current.key === captured) setEntries(next); }
107
+ catch (cause) { if (scope.current.mounted && request === generation.current && scope.current.key === captured) setError(cause instanceof Error ? cause.message : 'Memory unavailable'); }
108
+ finally { if (scope.current.mounted && request === generation.current && scope.current.key === captured) setLoading(false); }
109
+ }, [scopeKey, systemAllowed, tier, group]);
110
+ useEffect(() => { void load(); }, [load]);
74
111
  const validKey = /^[a-z0-9:_-]{1,64}$/.test(key); const validValue = value.length > 0 && value.length <= 500;
75
- const save = async () => { if (!validKey || !validValue || (tier === 'group' && group == null)) return; try { await saveAssistantMemory(tier, key, value, tier === 'group' ? group! : undefined); setKey(''); setValue(''); await load(); toast.success('Memory saved'); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Save failed'); } };
76
- const remove = async (entryKey: string) => { const confirmed = await modal.confirm({ title: 'Delete memory?', message: `Delete “${entryKey}” from ${tier} memory?`, confirmText: 'Delete', danger: true }); if (!confirmed) return; try { await deleteAssistantMemory(tier, entryKey, tier === 'group' ? group! : undefined); await load(); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Delete failed'); } };
77
- return <div><Heading eyebrow="Assistant" title="Memory" description="Manage global, personal, or explicitly selected group memory without ambient group state." /><div className="panel panel-pad assistant-memory-controls"><div className="seg">{(['global', 'user', 'group'] as MemoryTier[]).map((item) => <button key={item} className={`seg-btn${tier === item ? ' seg-active' : ''}`} onClick={() => setTier(item)}>{item}</button>)}</div>{tier === 'group' && <label className="field"><span className="field-label">Group (bounded first 50)</span><select className="input" value={group ?? ''} onChange={(event) => setGroup(event.target.value ? Number(event.target.value) : null)}><option value="">Choose a group</option>{groups.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>}<div className="assistant-memory-form"><label className="field"><span className="field-label">Key</span><input className="input" maxLength={64} value={key} onChange={(event) => setKey(event.target.value)} placeholder="lowercase:key" /></label><label className="field"><span className="field-label">Value</span><textarea className="input" maxLength={500} rows={2} value={value} onChange={(event) => setValue(event.target.value)} /></label><button className="btn btn-primary" disabled={!validKey || !validValue || loading || (tier === 'group' && group == null)} onClick={() => void save()}>Save</button></div>{key && !validKey && <p className="field-error">Use 1–64 lowercase letters, numbers, colon, underscore, or hyphen.</p>}{error && <div className="form-alert" role="alert">{error}</div>}</div><div className="panel assistant-list">{loading ? <p className="empty">Loading…</p> : Object.entries(entries).length ? <table><thead><tr><th>Key</th><th>Value</th><th /></tr></thead><tbody>{Object.entries(entries).slice(0, 100).map(([entryKey, entryValue]) => <tr key={entryKey}><td><code>{entryKey}</code></td><td>{String(entryValue).slice(0, 500)}</td><td><button className="btn-icon" aria-label={`Delete ${entryKey}`} onClick={() => void remove(entryKey)}><i className="bi bi-trash" /></button></td></tr>)}</tbody></table> : <p className="empty">No {tier} memory.</p>}</div></div>;
112
+ const mutate = async (operation: () => Promise<void>, captured: string) => {
113
+ if (lock.current || !scope.current.mounted || !scope.current.allowed || scope.current.key !== captured) return;
114
+ lock.current = true; setBusy(true); setError('');
115
+ try { await operation(); if (scope.current.mounted && scope.current.key === captured) await load(); }
116
+ catch (cause) { if (scope.current.mounted && scope.current.key === captured) setError(cause instanceof Error ? cause.message : 'Memory change failed'); }
117
+ finally { lock.current = false; if (scope.current.mounted) setBusy(false); }
118
+ };
119
+ const save = async () => { if (!validKey || !validValue) return; await mutate(() => saveAssistantMemory(tier, key, value, tier === 'group' ? group! : undefined), scopeKey); };
120
+ const remove = async (entryKey: string) => {
121
+ const frozen = { tier, group: tier === 'group' ? group! : undefined, key: scopeKey };
122
+ if (!canManage) return;
123
+ const confirmed = await modal.confirm({ title: 'Remove memory key?', message: `Remove “${entryKey}” from ${frozen.tier} memory${frozen.group == null ? '' : ` for group #${frozen.group} (${groups.find(item => item.id === frozen.group)?.name ?? 'Unknown'})`}? The previous value cannot be restored by this action.`, confirmText: 'Remove key', danger: true });
124
+ if (confirmed) await mutate(() => deleteAssistantMemory(frozen.tier, entryKey, frozen.group), frozen.key);
125
+ };
126
+ return <div><Heading eyebrow="Assistant" title="Memory" description="Manage global, personal, or explicitly selected group memory without ambient group state." />
127
+ <div className="panel panel-pad assistant-memory-controls"><div className="seg">{(['global', 'user', 'group'] as MemoryTier[]).map(item => <button key={item} disabled={busy} className={`seg-btn${tier === item ? ' seg-active' : ''}`} onClick={() => setTier(item)}>{item}</button>)}</div>
128
+ {tier === 'group' && <label className="field"><span className="field-label">Group (bounded first 50)</span><select className="input" value={group ?? ''} disabled={busy} onChange={event => setGroup(event.target.value ? Number(event.target.value) : null)}><option value="">Choose a group</option>{groups.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>}
129
+ {!canManage && <p className="dim">Memory editing requires system Assistant permission and, for a selected group, inherited member Assistant authority or superuser access.</p>}
130
+ <div className="assistant-memory-form"><label className="field"><span className="field-label">Key</span><input className="input" disabled={!canManage || busy} maxLength={64} value={key} onChange={event => setKey(event.target.value)} placeholder="lowercase:key" /></label><label className="field"><span className="field-label">Value</span><textarea className="input" disabled={!canManage || busy} maxLength={500} rows={2} value={value} onChange={event => setValue(event.target.value)} /></label><button className="btn btn-primary" disabled={!canManage || busy || !validKey || !validValue || loading} onClick={() => void save()}>Save</button></div>{error && <div className="form-alert" role="alert">{error}</div>}</div>
131
+ <div className="panel assistant-list">{loading ? <p className="empty">Loading…</p> : Object.entries(entries).length ? <table><thead><tr><th>Key</th><th>Value</th><th /></tr></thead><tbody>{Object.entries(entries).slice(0, 100).map(([entryKey, entryValue]) => <tr key={entryKey}><td><code>{entryKey}</code></td><td>{String(entryValue).slice(0, 500)}</td><td>{canManage && <button className="btn-icon" disabled={busy} aria-label={`Remove ${entryKey}`} onClick={() => void remove(entryKey)}><i className="bi bi-trash" /></button>}</td></tr>)}</tbody></table> : <p className="empty">No {tier} memory.</p>}</div></div>;
78
132
  }
@@ -0,0 +1,9 @@
1
+ import { mojoCall } from '../../client/runtime';
2
+ import { getAssistantSkill } from './api';
3
+ import type { AssistantSkill } from './types';
4
+
5
+ /** Lifecycle writes use existing REST save; conversation history is never removed. */
6
+ export async function setAssistantSkillActive(id: number, isActive: boolean): Promise<AssistantSkill> {
7
+ await mojoCall(`/api/assistant/skill/${id}`, { method: 'POST', body: { is_active: isActive } });
8
+ return getAssistantSkill(id);
9
+ }
@@ -55,7 +55,8 @@ export interface AdminSection {
55
55
  icon: string;
56
56
  permissions: string[];
57
57
  capability?: string;
58
- navigationGroup?: AdminNavigationGroup;
58
+ /** Set to null to keep this section's routes at the top level of a grouped menu. */
59
+ navigationGroup?: AdminNavigationGroup | null;
59
60
  routes: AdminRoute[];
60
61
  }
61
62
 
@@ -202,13 +203,17 @@ export function adminSectionsMenu(sections: readonly AdminSection[], opts: {
202
203
  if (opts.grouped) {
203
204
  const groups = new Map<AdminNavigationGroup, MenuItem[]>();
204
205
  for (const section of sections) {
205
- const group = section.navigationGroup ?? 'other';
206
206
  const base = relativePath(mount, section.basePath ?? section.id);
207
207
  const labeled = section.routes.filter((route) => route.label);
208
208
  const destinations: MenuItem[] = labeled.length > 0 ? labeled.map((route) => ({
209
209
  id: `admin:${section.id}:${route.path || 'index'}`, label: route.label!, keywords: [section.title],
210
210
  route: absolutePath(base, route.path), permissionClauses: [section.permissions, ...(route.permissions ? [route.permissions] : [])],
211
211
  })) : [{ id: `admin:${section.id}`, label: section.title, keywords: [section.title], route: absolutePath(base), permissionClauses: [section.permissions] }];
212
+ if (section.navigationGroup === null) {
213
+ items.push(...destinations.map((destination) => ({ ...destination, icon: section.icon })));
214
+ continue;
215
+ }
216
+ const group = section.navigationGroup ?? 'other';
212
217
  groups.set(group, [...(groups.get(group) ?? []), ...destinations]);
213
218
  }
214
219
  for (const [group, children] of [...groups.entries()].sort((a, b) => ADMIN_NAVIGATION_GROUPS[a[0]].order - ADMIN_NAVIGATION_GROUPS[b[0]].order)) {
@@ -1,24 +1,46 @@
1
- import { useContext, useSyncExternalStore } from 'react';
2
1
  import { useQueryClient } from '@tanstack/react-query';
2
+ import { useContext, useSyncExternalStore } from 'react';
3
3
  import {
4
- GroupContext, hasPermission, useCan, useMe,
4
+ GroupContext,
5
+ hasPermission,
6
+ useCan,
7
+ useMe,
5
8
  type PermSpec,
6
9
  } from '../../client/runtime';
7
10
  import {
8
- ArmedButton, Badge, DetailView, Eyebrow, FlatRow, ModelTable,
9
- SchemaForm, fmt, modal, toast,
10
- type Column, type Field, type FilterDef,
11
+ Badge,
12
+ DetailView,
13
+ Eyebrow,
14
+ FlatRow,
15
+ ModelTable,
16
+ SchemaForm,
17
+ confirmGuardrail,
18
+ fmt,
19
+ modal,
20
+ toast,
21
+ type Column,
22
+ type Field,
23
+ type FilterDef,
11
24
  } from '../../ui';
25
+ import { ApiKeyLimitsSummary, ApiKeyRateLimitsEditor } from './api-key-rate-limits';
12
26
  import {
13
- GLOBAL_CREDENTIAL_PERMS, GROUP_CREDENTIAL_PERMS,
14
- GroupApiKeyModel, buildApiKeyPermissionChanges, customApiKeyPermissionNames,
27
+ GLOBAL_CREDENTIAL_PERMS,
28
+ GROUP_CREDENTIAL_PERMS,
29
+ GroupApiKeyModel,
30
+ buildApiKeyPermissionChanges,
31
+ customApiKeyPermissionNames,
15
32
  fetchApiKeyToken,
16
- getGroupApiKeyPermissions, grantedPermissions,
17
- groupApiKeyPermissionsVersion, subscribeGroupApiKeyPermissions,
18
- normalizeApiKeyPermissionNames, readApiKeyRateLimits, useCreateGroupApiKey,
19
- type ApiKeyPermissionDef, type CredentialGroup, type GroupApiKeyRow,
33
+ getGroupApiKeyPermissions,
34
+ grantedPermissions,
35
+ groupApiKeyPermissionsVersion,
36
+ normalizeApiKeyPermissionNames,
37
+ readApiKeyRateLimits,
38
+ subscribeGroupApiKeyPermissions,
39
+ useCreateGroupApiKey,
40
+ type ApiKeyPermissionDef,
41
+ type CredentialGroup,
42
+ type GroupApiKeyRow,
20
43
  } from './models';
21
- import { ApiKeyLimitsSummary, ApiKeyRateLimitsEditor } from './api-key-rate-limits';
22
44
  import { showSecretDialog } from './secret-dialog';
23
45
 
24
46
  function groupLabel(group: GroupApiKeyRow['group']): string {
@@ -91,11 +113,25 @@ function TokenFooter({ permissions }: { permissions: string[] }) {
91
113
  );
92
114
  }
93
115
 
116
+ /** The stop in front of `is_active: false` — copy states what the backend does. */
117
+ function confirmDeactivateKey(row: GroupApiKeyRow): Promise<boolean> {
118
+ return confirmGuardrail({
119
+ title: `Deactivate API key "${row.name}"?`,
120
+ effect: <>The key stops authenticating the moment this saves.</>,
121
+ why: [
122
+ <>Every integration holding this key gets <b>401</b> on its next call — the caller sees an auth failure, not a notice.</>,
123
+ <>The key is <b>not rotated</b>: the same secret stays on file, so anyone who holds it regains access the instant it is re-enabled.</>,
124
+ ],
125
+ undo: 'Re-enabling restores the same key; no integration needs a new secret.',
126
+ confirmText: 'Deactivate key',
127
+ });
128
+ }
129
+
94
130
  function useGroupApiKeyActions(permission: PermSpec) {
95
131
  const renderedPermissions = useRenderedPermissions();
96
132
  const create = useCreateGroupApiKey();
97
133
  const save = GroupApiKeyModel.useSave();
98
- const destroy = GroupApiKeyModel.useDelete();
134
+
99
135
  const { can } = useCan(permission);
100
136
 
101
137
  const createKey = async (fixedGroup?: CredentialGroup) => {
@@ -220,6 +256,11 @@ function useGroupApiKeyActions(permission: PermSpec) {
220
256
  if (Object.keys(permissionChanges).length) changes.permissions = permissionChanges;
221
257
  if (!Object.keys(changes).length) return;
222
258
 
259
+ // Flipping a key inactive is the one edit here with a blast radius
260
+ // outside this portal — every caller holding the key. Guardrail it;
261
+ // the other edits in the same form ride along only if it is confirmed.
262
+ if (changes.is_active === false && !(await confirmDeactivateKey(row))) return;
263
+
223
264
  try {
224
265
  await save.mutateAsync({ id: row.id, changes });
225
266
  toast.success('API key updated');
@@ -244,19 +285,19 @@ function useGroupApiKeyActions(permission: PermSpec) {
244
285
  }
245
286
  };
246
287
 
247
- const deleteKey = async (row: GroupApiKeyRow) => {
288
+ const toggleKey = async (row: GroupApiKeyRow) => {
248
289
  if (!can) return false;
249
290
  try {
250
- await destroy.mutateAsync({ id: row.id });
251
- toast.success('API key deleted');
291
+ await save.mutateAsync({ id: row.id, changes: { is_active: !row.is_active } });
292
+ toast.success(row.is_active ? 'API key deactivated' : 'API key reactivated');
252
293
  return true;
253
294
  } catch (error) {
254
- toast.error(error instanceof Error ? error.message : 'Failed to delete API key');
295
+ toast.error(error instanceof Error ? error.message : 'Failed to update API key');
255
296
  return false;
256
297
  }
257
298
  };
258
299
 
259
- return { canManage: can, createKey, editKey, revealToken, deleteKey };
300
+ return { canManage: can, createKey, editKey, revealToken, toggleKey, pending: save.isPending };
260
301
  }
261
302
 
262
303
  function ApiKeyCard({ row, actions }: {
@@ -299,13 +340,7 @@ function ApiKeyCard({ row, actions }: {
299
340
  <button className="btn btn-compact" title="Reveal the current token" onClick={() => void actions.revealToken(row)}>
300
341
  <i className="bi bi-eye" /> Token
301
342
  </button>
302
- <ArmedButton
303
- className="btn-compact"
304
- label={<i className="bi bi-trash" aria-label="Delete this key" />}
305
- armedLabel="Click again — services using this key lose access"
306
- title="Delete this key"
307
- onConfirm={async () => { await actions.deleteKey(row); }}
308
- />
343
+ <button className="btn btn-compact" disabled={actions.pending} onClick={() => void actions.toggleKey(row)}>{row.is_active ? 'Deactivate' : 'Reactivate'}</button>
309
344
  </div>
310
345
  )}
311
346
  </div>
@@ -369,6 +404,12 @@ function AuthorizedGroupApiKeyDetail({ id, onClose }: { id: number; onClose: ()
369
404
  { text: `${permissions.length} grants`, tone: 'info' },
370
405
  limitsChip,
371
406
  ]}
407
+ active={actions.canManage ? { value: row.is_active, disabled: actions.pending, onChange: () => void actions.toggleKey(row) } : undefined}
408
+ contextMenu={actions.canManage ? [
409
+ { label: 'Edit key', onSelect: () => void actions.editKey(row) },
410
+ { label: 'Reveal audited token', onSelect: () => void actions.revealToken(row) },
411
+ { label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: actions.pending, onSelect: () => void actions.toggleKey(row) },
412
+ ] : []}
372
413
  sections={[
373
414
  {
374
415
  key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
@@ -378,24 +419,7 @@ function AuthorizedGroupApiKeyDetail({ id, onClose }: { id: number; onClose: ()
378
419
  <FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
379
420
  <FlatRow label="Last used">{fmt.relative(row.last_used, 'never')}</FlatRow>
380
421
  <FlatRow label="Expires">{row.expires_at ? fmt.datetime(row.expires_at) : 'Never'}</FlatRow>
381
- {actions.canManage && (
382
- <div className="ga-toolbar" style={{ marginTop: 16 }}>
383
- <button className="btn btn-compact" onClick={() => void actions.editKey(row)}>
384
- <i className="bi bi-pencil" /> Edit
385
- </button>
386
- <button className="btn btn-compact" onClick={() => void actions.revealToken(row)}>
387
- <i className="bi bi-eye" /> Reveal audited token
388
- </button>
389
- <ArmedButton
390
- className="btn-compact"
391
- label="Delete"
392
- armedLabel="Click again — delete now"
393
- onConfirm={async () => {
394
- if (await actions.deleteKey(row)) onClose();
395
- }}
396
- />
397
- </div>
398
- )}
422
+
399
423
  </>
400
424
  ),
401
425
  },