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.
- package/README.md +3 -0
- package/docs/DetailView.md +19 -15
- package/docs/ModelTable.md +26 -0
- package/docs/README.md +2 -1
- package/docs/admin-assistant.md +3 -1
- package/docs/admin-credentials.md +7 -0
- package/docs/admin-dashboard.md +1 -1
- package/docs/admin-dns.md +8 -2
- package/docs/admin-incidents.md +26 -3
- package/docs/admin-jobs.md +7 -4
- package/docs/admin-messaging.md +6 -5
- package/docs/admin-modals.md +122 -0
- package/docs/admin-network-security.md +4 -4
- package/docs/admin-phonehub.md +24 -6
- package/docs/admin-push.md +7 -0
- package/docs/admin-rules.md +8 -0
- package/docs/admin-shortlinks.md +2 -1
- package/docs/admin-storage.md +2 -1
- package/docs/auth.md +28 -0
- package/docs/client.md +12 -0
- package/docs/detail-primitives.md +12 -1
- package/docs/feedback.md +70 -1
- package/docs/forms.md +51 -0
- package/docs/loading.md +3 -2
- package/docs/popover.md +18 -3
- package/package.json +1 -1
- package/src/admin/assistant/api.ts +1 -1
- package/src/admin/assistant/pages.tsx +71 -17
- package/src/admin/assistant/skill-lifecycle.ts +9 -0
- package/src/admin/core/index.ts +7 -2
- package/src/admin/credentials/group-api-keys.tsx +67 -43
- package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
- package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
- package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
- package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
- package/src/admin/dns/setup.ts +1 -1
- package/src/admin/domains/observability.ts +1 -1
- package/src/admin/identity/members/MemberDetail.tsx +48 -3
- package/src/admin/identity/users/UserDetail.tsx +36 -20
- package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
- package/src/admin/identity/users/sections/actions.tsx +20 -9
- package/src/admin/incidents/EventDetail.tsx +147 -5
- package/src/admin/incidents/IncidentDetail.tsx +181 -8
- package/src/admin/incidents/models.ts +1 -1
- package/src/admin/jobs/RunnerDetail.tsx +5 -8
- package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
- package/src/admin/jobs/index.ts +1 -1
- package/src/admin/jobs/purge-preview.ts +25 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
- package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
- package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
- package/src/admin/messaging/MailboxesPage.tsx +3 -3
- package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
- package/src/admin/messaging/push/PushPage.tsx +20 -8
- package/src/admin/monitoring/LogInspector.tsx +3 -19
- package/src/admin/network/IPSetDetail.tsx +32 -63
- package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
- package/src/admin/phonehub/SmsComposer.tsx +97 -0
- package/src/admin/phonehub/api.ts +11 -5
- package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
- package/src/admin/rules/condition-removal.ts +7 -0
- package/src/admin/security/geoip/models.ts +5 -0
- package/src/admin/security/tickets.tsx +40 -17
- package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
- package/src/admin/storage/BucketDetail.tsx +28 -9
- package/src/admin/storage/FileManagerDetail.tsx +23 -5
- package/src/admin/storage/FileView.tsx +28 -16
- package/src/admin/storage/FilesPage.tsx +4 -5
- package/src/client/auth.ts +10 -1
- package/src/client/client.ts +14 -6
- package/src/client/mock.ts +90 -6
- package/src/client/upload.ts +1 -0
- package/src/ui/DetailView.tsx +22 -5
- package/src/ui/FormView.tsx +24 -1
- package/src/ui/ModelTable.tsx +12 -4
- package/src/ui/Popover.tsx +13 -12
- package/src/ui/detail/MetadataSection.tsx +46 -10
- package/src/ui/form-autosave.ts +134 -23
- package/src/ui/guardrail.tsx +125 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/modal.tsx +8 -4
- 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
|
-
|
|
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
|
|
242
|
-
|
|
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
|
-
- **
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
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 <
|
|
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
|
|
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
|
-
|
|
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
|
|
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 }) {
|
|
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() {
|
|
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
|
|
72
|
-
|
|
73
|
-
const
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
}
|
package/src/admin/core/index.ts
CHANGED
|
@@ -55,7 +55,8 @@ export interface AdminSection {
|
|
|
55
55
|
icon: string;
|
|
56
56
|
permissions: string[];
|
|
57
57
|
capability?: string;
|
|
58
|
-
|
|
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,
|
|
4
|
+
GroupContext,
|
|
5
|
+
hasPermission,
|
|
6
|
+
useCan,
|
|
7
|
+
useMe,
|
|
5
8
|
type PermSpec,
|
|
6
9
|
} from '../../client/runtime';
|
|
7
10
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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,
|
|
14
|
-
|
|
27
|
+
GLOBAL_CREDENTIAL_PERMS,
|
|
28
|
+
GROUP_CREDENTIAL_PERMS,
|
|
29
|
+
GroupApiKeyModel,
|
|
30
|
+
buildApiKeyPermissionChanges,
|
|
31
|
+
customApiKeyPermissionNames,
|
|
15
32
|
fetchApiKeyToken,
|
|
16
|
-
getGroupApiKeyPermissions,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
288
|
+
const toggleKey = async (row: GroupApiKeyRow) => {
|
|
248
289
|
if (!can) return false;
|
|
249
290
|
try {
|
|
250
|
-
await
|
|
251
|
-
toast.success('API key
|
|
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
|
|
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,
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
},
|