portal-mojo 0.2.18 → 0.2.19

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 CHANGED
@@ -145,3 +145,15 @@ The non-negotiables live in the repo root `PLAN.md` ("Architecture rules") and
145
145
  boundary; failure is unmissable; controlled inputs only; models are
146
146
  definitions + hooks (TanStack Query owns cache); `ReactNode` slots, never
147
147
  trusted-HTML strings; both themes from day one.
148
+
149
+ ### Owner-only AI Assistant setup
150
+
151
+ The Assistant domain includes a dedicated `/assistant/setup` page (also embedded
152
+ under the Admin mount prefix), protected by the additive literal-superuser
153
+ `AdminRoute.requireSuperuser` / `MenuItem.requireSuperuser` gate. It provides
154
+ explicit key check/save and enablement, with separate advanced Platform key,
155
+ deployed policy/safety, remote access and incident triage sections. Setup secrets
156
+ and actions stay page-local and cache-free. Fresh-auth requires key re-entry,
157
+ and deployment owns the runtime route. See [Admin Assistant](docs/admin-assistant.md)
158
+ for exact schema-2 actions, lifecycle guarantees, mock showcase and verification;
159
+ [Admin credentials](docs/admin-credentials.md) documents the shared core gate.
package/docs/README.md CHANGED
@@ -41,7 +41,7 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
41
41
  | [admin-messaging.md](admin-messaging.md) | SES domains/mailboxes/sent/templates plus public contact requests, with write-only credentials and offline HTML previews |
42
42
  | [admin-phonehub.md](admin-phonehub.md) | Global Phone Hub: evidence-backed lookup, sanitized SMS audits, and write-only provider configuration |
43
43
  | [admin-push.md](admin-push.md) | Global Push admin: caller stats, global metrics, strict audits, templates, and cache-free FCM configuration |
44
- | [admin-assistant.md](admin-assistant.md) | Global Assistant streaming selection/reconciliation, strict projectors/blocks, context reuse, skills, and explicit tiered memory |
44
+ | [admin-assistant.md](admin-assistant.md) | Owner-only AI Assistant Setup, credential/safety/remote controls and global Assistant streaming selection/reconciliation, strict projectors/blocks, context reuse, skills, and explicit tiered memory |
45
45
  | [ModelTable.md](ModelTable.md) | The server-driven table: columns, filters, selection/batch, chooser, persist, autoRefresh, expand, groupBy, export, skeleton |
46
46
  | [forms.md](forms.md) | The `Field` language, `SchemaForm`, `formModal`, `FormView` inline autosave + showWhen + tabsets |
47
47
  | [tabs-and-form-wizard.md](tabs-and-form-wizard.md) | Accessible Tabs variants and shared-state FormWizard, async finish, reset/roster semantics, modal helper |
@@ -2,7 +2,158 @@
2
2
 
3
3
  Import from `portal-mojo/admin`.
4
4
 
5
- `ASSISTANT_ADMIN_SECTION` registers Conversations, Skills, and Memory beneath the Assistant navigation group. Its section clause is global-only ANY-of `sys.view_admin` or `sys.assistant`; Memory narrows its route to `sys.assistant`, matching the backend. Active-group grants never reveal it. `AssistantLauncher` uses the section gate. `AssistantContextLauncher` additionally requires `sys.view_security` or `sys.security` and only accepts `incident.Incident` or `incident.Ticket`.
5
+ `ASSISTANT_ADMIN_SECTION` registers Setup, Conversations, Skills, and Memory beneath the Assistant navigation group. Its section clause is global-only ANY-of `sys.view_admin` or `sys.assistant`; Memory narrows its route to `sys.assistant`, matching the backend. Active-group grants never reveal it. `AssistantLauncher` uses the section gate. `AssistantContextLauncher` additionally requires `sys.view_security` or `sys.security` and only accepts `incident.Incident` or `incident.Ticket`.
6
+
7
+ ## AI Assistant setup
8
+
9
+ `AssistantSetupPage` is a dedicated global owner page at `/assistant/setup`, or
10
+ `/system/assistant/setup` when the Admin helpers receive `mount: '/system'`.
11
+ The literal `me.is_superuser === true` requirement applies before lazy loading,
12
+ in grouped and ordinary menu entries, landing selection, direct component
13
+ mounting and every action. Permission-driven `admin`, `manage_settings`,
14
+ Assistant permissions and member/group grants cannot satisfy it. The server
15
+ also rejects confined ApiKey/GroupScopedToken sessions and enforces person
16
+ ownership; the client does not invent a broader per-user-key ban.
17
+
18
+ The basic section shows deployment readiness, exact provider/model/key
19
+ selection, Automatic — latest Sonnet or an explicit manual pin, credential source and optional server hint, blank replacement input,
20
+ Check and save key, Check current key and enable/disable. A configured key and
21
+ an enabled switch do not prove readiness. `route.ready` describes configuration,
22
+ not current provider health. Unknown routes remain unknown: a missing
23
+ credential can cause the backend to omit the entire route. Only an explicit
24
+ `handler` route points to the Platform credential section. Bedrock uses AWS
25
+ identity from deployment. Assistant, Platform and AWS credentials never
26
+ substitute for one another. Missing Assistant/realtime applications are also
27
+ reported. Runtime model identifiers preserve provider punctuation, including
28
+ Bedrock colons; only a manual pin uses the backend's restricted pin syntax.
29
+
30
+ Advanced native details separate Platform credentials, deployed policy/safety,
31
+ remote access/connections and incident triage. Policy activation acknowledges
32
+ the deployed policy on this node, does not edit it and never establishes
33
+ fleet-wide agreement. Static, database and effective emergency stops are
34
+ separate; only the database switch is writable. Activation and breaker reset
35
+ cannot release a stop.
36
+
37
+ Model selection controls the protected runtime selection only when the backend
38
+ returns a valid capability for the explicit Anthropic `auto:sonnet` policy.
39
+ Fixed Anthropic policies, Bedrock and unavailable policies remain read-only.
40
+ A deployment pin permits an owner manual override, but Automatic remains
41
+ unavailable until deployment removes that pin. Mode is authoritative even when
42
+ a fixed route retains an inert legacy `selected` value.
43
+
44
+ The exact resolved runtime identifier (`model.effective`) and source are
45
+ separate from the selection draft and `route.ready`. Advanced → Deployed policy
46
+ and safety contains the raw policy model and admission diagnostic. The additive
47
+ `route.can_invoke` reports admission to the guarded request path: a cold
48
+ automatic catalogue can admit a request while configuration readiness remains
49
+ false. It does not prove that a usable model or healthy provider is ready.
50
+
51
+ Refresh catalogue is an explicit backend request; only the backend contacts the
52
+ provider. The browser never decides which model is newest. A refresh announces
53
+ success only for `model.refresh.ok === true`, including when a manual pin
54
+ remains selected. Failed/throttled/in-flight attempts may retain a fresh or
55
+ stale cached resolution; the page reports its exact freshness, last successful
56
+ fetch and attempt without claiming a newer model. Passive reads neither refresh
57
+ the catalogue nor repeat the preceding explicit refresh outcome.
58
+
59
+ Check public discovery explicitly probes this installation's public address
60
+ (server cache: 60 seconds). Ordinary page reads trigger neither probe.
61
+
62
+ Both credential drafts and all setup operations stay imperative and page-local.
63
+ There is no autosave, model definition, Query/Mutation entry, browser
64
+ persistence, export, URL value or secret-bearing mock history. The backend
65
+ checks a replacement before encrypted storage; a candidate-only verification
66
+ never updates the stored-key check record. Successful credential saves and
67
+ clears empty the relevant draft. Clearing protected storage may reveal a
68
+ deployment fallback, so the returned source remains authoritative. Fixed
69
+ backend pre-write rejection/unreachable messages map to safe actionable copy;
70
+ unknown failures refresh authoritative state and report an uncertain outcome
71
+ without replaying the operation.
72
+
73
+ One operation/confirmation lock, AbortSignal and live owner/session guard cover
74
+ loads, confirmations, auth waits and late responses. Leaving the page,
75
+ revocation, logout or explicit session replacement discards drafts and pending
76
+ work. Ordinary same-tab token renewal preserves a draft. Cross-tab auth-storage
77
+ changes conservatively discard it: the shared auth API cannot distinguish
78
+ renewal from same-person replacement across tabs. A 440 uses the existing
79
+ fresh-auth UI after clearing credential drafts; even successful reauthentication
80
+ requires explicit re-entry/retry. No secret is silently replayed. Confirmation
81
+ is page-local, focuses Cancel, and ends with the page/session scope.
82
+
83
+ Protocol helpers are exported from `portal-mojo/admin/assistant`:
84
+ `getAssistantSetup(options, probe?)`, `mutateAssistantSetup(action, options)`,
85
+ `assistantSetupSave(state, changes)`, `projectAssistantSetup`,
86
+ `assistantSetupReadiness`, and `assistantSetupCredentialRefusal`. Options accept
87
+ `signal` and a `beforeSend` validity guard; mutation helpers enforce same-origin
88
+ before and after auth preflight and use the existing `mojoCall` envelope boundary.
89
+ `schema_version: 2` responses are positively projected with bounds of 200
90
+ model choices/connections/request aggregates and 100 breaker rows. Runtime IDs
91
+ preserve provider punctuation; only manual pins use the backend's restricted
92
+ 81-character lowercase alphanumeric/dot/hyphen syntax.
93
+
94
+ The additive wire fields live directly under `model`: `mode` (`auto`, `pinned`,
95
+ `fixed`), `editable`, `policy_model`, `restriction` (empty, `fixed_policy`,
96
+ `provider_unsupported`, `deployment_pin`, `policy_unavailable`), `discovery`
97
+ (`status`: `fresh`, `stale`, `unavailable`, `not_applicable`; nullable ISO
98
+ `fetched_at`, `attempted_at`, `expires_at`, `max_stale_at`; safe `error_code`),
99
+ and `refresh` (`attempted`, nullable boolean `ok`, safe `code`). The client
100
+ positively projects the complete valid set into optional `model.capability`.
101
+ Absent, malformed or unknown capabilities keep runtime facts visible, with
102
+ upgrade/read-only copy and no enabled selection action. No latest ID ships in
103
+ the production UI; returned bounded choices supply the datalist.
104
+
105
+ | Request | Exact body / query |
106
+ |---|---|
107
+ | Passive GET `/api/account/admin/assistant` | No probe parameters |
108
+ | Explicit catalogue / discovery GET | `refresh=models` / `check=discovery` |
109
+ | Save POST | `{action:'save',enabled:boolean,model?:string}` plus only intended optional `api_key`, `clear_api_key`, `handler_api_key`, `clear_handler_api_key`, `mcp_enabled`, `emergency_stop`, `autonomous_triage` |
110
+ | Model selection POST | Exactly `{action:'set_model_selection',mode:'auto'}` (no `model`) or `{action:'set_model_selection',mode:'pinned',model:ID}`; `saved:boolean` |
111
+ | Verify POST | `{action:'verify',target:'assistant'|'handler',api_key?}`; omitted candidate checks that exact stored credential |
112
+ | Policy POST | Exactly `{action:'activate_policy'}`; `activated:boolean` |
113
+ | Breaker POST | `{action:'reset_breaker',provider?:'anthropic'|'bedrock'}`; `reset:number` |
114
+ | Historical triage POST | Exactly `{action:'historical_triage',before:ISO,limit:1..100}`; `queued:number` |
115
+ | Disconnect POST | Exactly `{action:'revoke_grant',grant_id:positiveInteger}` or `{action:'revoke_all_grants'}`; `revoked:number` |
116
+
117
+ Every mutation returns fresh `state` plus the action result. Verification may
118
+ return `result.ok:false` in a successful envelope; that remains a failed check.
119
+ Save always sends authoritative `enabled`. With a valid capability it omits
120
+ `model` and cannot reset runtime selection; only the dedicated selection action
121
+ can change it. Capability-absent older schema-2 servers retain the required
122
+ `enabled`/legacy `model` baseline because they write both even when omitted.
123
+ On delegated `auto:sonnet` routes, new servers reject a differing legacy model
124
+ echo, preventing stale clients from reverting the active choice. Fixed policies,
125
+ Bedrock and unavailable-policy routes retain an inert legacy writer; these
126
+ values cannot change their deployed runtime model. Model actions carry no
127
+ enabled/key/stop/MCP/triage fields, and all selection drafts reset from each
128
+ authoritative response. Optional section switches are not
129
+ echoed during unrelated saves, and effective stop is never used as the database
130
+ stop. Replacement and clear are separate operations. The server retains its
131
+ 600-second fresh-auth, protected settings, auditing and same-origin checks;
132
+ generic Settings still cannot write the Assistant-owned protected keys.
133
+
134
+ Remote access off pauses existing grants without revoking them. The connection
135
+ count remains the full server count when rows are capped at 200. Tools, Full API
136
+ and combined grants are labeled separately; Full API carries the signed-in
137
+ user's API authority. Disconnect all spans both resource paths for every user.
138
+ Returned addresses are inert text/read-only select-to-copy inputs. Triage
139
+ activation establishes the no-history watermark; historical processing requires
140
+ an explicit cutoff, bounded limit and confirmation.
141
+
142
+ The mock-only Assistant showcase adds Setup with explicit fake owner/denied
143
+ permission-admin buttons, a one-shot fresh-auth challenge button, and ready, missing policy/key, handler, Bedrock, static
144
+ stop, rejected key, discovery failure, bounded-grants and deployment-fallback
145
+ scenarios. Model scenarios include manual pins, fixed policy, deployment pin
146
+ (including an owner override), fresh/stale refresh failure, unavailable
147
+ resolution, throttled/in-flight refresh, old-server capability absence and
148
+ malformed capability. `fixture-sonnet-2099-01` and `fixture-sonnet-2099-02` are
149
+ visibly fake future IDs; explicit catalogue refresh advances the fake catalogue,
150
+ preserves manual pins, and returning to Automatic adopts the successor. Seeded `assistant.owner@nativemojo.com` is a literal superuser;
151
+ `assistant.wildcard@nativemojo.com` has permission-driven admin only. Both use
152
+ fake password `mojo`. No real credentials or deployment changes belong in demos.
153
+ `verify:admin-assistant-setup` covers wire contracts, owner navigation/admission,
154
+ projections and mounted auth/draft/confirmation/fresh-auth/transport/cache
155
+ lifecycle; it is wired into `verify:release`. Browser checks separately establish
156
+ both-theme layout, native details, keyboard controls and console behavior.
6
157
 
7
158
  ## Conversation transport
8
159
 
@@ -26,6 +26,17 @@ use `basePath: ''`; its labeled routes become visible child links rather than
26
26
  claiming `/`. Prefixed sections receive an index redirect to the first route
27
27
  the current operator can see.
28
28
 
29
+ ## Literal-owner routes
30
+
31
+ `AdminRoute.requireSuperuser?: boolean` is an additive opt-in gate. It requires
32
+ literal `me.is_superuser === true` before a lazy page can load and during
33
+ section/mount landing selection. `adminSectionsMenu` carries it into grouped
34
+ and ordinary `MenuItem.requireSuperuser` entries. Permission-driven admin and
35
+ group grants never satisfy it; existing permission semantics remain unchanged.
36
+ Built-in Assistant Setup uses this gate and additionally checks live ownership
37
+ and session lifetime within the component/actions. Toolkit consumers can opt
38
+ into the same route/menu guard without changing other routes.
39
+
29
40
  ## Group API keys
30
41
 
31
42
  ```tsx
@@ -62,6 +62,16 @@ per endpoint, so `UserDetail` and the fleet-wide tables share one cache.
62
62
 
63
63
  ## Action contracts
64
64
 
65
+ - **Set temporary password** is available in the user header menu and Security
66
+ section with `sys.users | sys.manage_users`. Confirmation identifies the user
67
+ and explains that their password is replaced and all sessions are revoked.
68
+ `POST /api/account/admin/user/password/temporary` sends only `{user: id}`
69
+ through `withFreshAuth` (440 prompts and retries once). The generated password
70
+ appears once in a copyable dialog; closing or losing permission removes it.
71
+ It never enters Query/Mutation cache, storage, or a toast. The server requires
72
+ a person session, restricts superuser recovery to superusers, and forces the
73
+ recipient to replace the password before completing sign-in. This uses the
74
+ existing django-mojo endpoint; no backend upgrade or new permission is added.
65
75
  - Username changes use the `change_username` POST_SAVE_ACTION, not a direct
66
76
  field save. Model saves/actions retry one fresh-auth 440 automatically;
67
77
  combined `disable_totp` writes explicitly use `withFreshAuth`.
@@ -45,7 +45,7 @@ the type checker and was the source's silent-failure vector) and the
45
45
 
46
46
  | Fn | Notes |
47
47
  |---|---|
48
- | `fmt.date(v, fallback='—')` / `fmt.datetime(v, fallback='—')` | `MM/DD/YYYY` / `Mon D, YYYY h:mm` — accepts **epoch seconds** (the django-mojo wire shape), ms, ISO, Date |
48
+ | `fmt.date(v, fallback='—')` / `fmt.datetime(v, fallback='—')` | `MM/DD/YYYY` / `Mon D, YYYY h:mm` — accepts **epoch seconds** (the django-mojo wire shape), ms, ISO, Date. A **date-only** value (`'YYYY-MM-DD'`, `'YYYY-MM'`, `'YYYY'`) shows its own calendar day at its own precision in every browser zone, with no time: `10/17/2026` / `Oct 17, 2026`, `10/2026` / `Oct 2026`, `2026` / `2026`; `groupByDay`/`groupByRecency` bucket it under that same day |
49
49
  | `fmt.relative(v, fallback='Never')` | "3 weeks ago" style |
50
50
  | `fmt.relativeShort(v, fallback='—')` | compact age for chips: `now` / `5h` / `3d` / `3w` / `2mo` / `1y`. **Magnitude only** — a future date reads the same as a past one (use `relative` when direction matters). Source boundaries kept (`>`, not `>=`): 7d is `7d`, 8d is `1w` |
51
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.18",
3
+ "version": "0.2.19",
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",
@@ -34,7 +34,7 @@ export function shortDate(epochSeconds: number | null | undefined): string {
34
34
  }
35
35
 
36
36
  function passkeyIcon(row: PasskeyRow): string {
37
- const transports = (row.transports ?? '').split(',');
37
+ const transports = Array.isArray(row.transports) ? row.transports : [];
38
38
  if (transports.includes('usb') || transports.includes('nfc')) return 'bi-usb-drive';
39
39
  const name = (row.friendly_name ?? '').toLowerCase();
40
40
  if (/iphone|android|phone/.test(name)) return 'bi-phone';
@@ -20,7 +20,8 @@ export interface PasskeyRow {
20
20
  rp_id: string;
21
21
  is_enabled: boolean;
22
22
  sign_count: number;
23
- transports: string | null;
23
+ /** WebAuthn transport hints as django-mojo stores them: a JSON list, `[]` when none. */
24
+ transports: string[];
24
25
  aaguid: string | null;
25
26
  last_used: number | null;
26
27
  created: number;
@@ -0,0 +1,196 @@
1
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { useAuthSnapshot, useMe, type Me } from '../../client/me';
4
+ import { getAuthSessionRevision, getAuthSnapshot, isReauthRequired, requestFreshAuth, subscribeAuth } from '../../client/auth';
5
+ import {
6
+ assistantSetupCredentialRefusal, assistantSetupReadiness, assistantSetupSave, getAssistantSetup, mutateAssistantSetup,
7
+ type AssistantSetupState, type SetupAction, type SetupKey, type SetupCheck, type SetupTarget,
8
+ } from './setup';
9
+
10
+ type Operation = { controller: AbortController; revision: number };
11
+ /** Also gates direct component consumers, independently of navigation and lazy-route admission. */
12
+ export function AssistantSetupPage() {
13
+ const { data: me } = useMe();
14
+ const auth = useAuthSnapshot();
15
+ const revision = useSyncExternalStore(subscribeAuth, getAuthSessionRevision, getAuthSessionRevision);
16
+ if (!auth.authenticated || me?.is_superuser !== true || String(me.id) !== auth.uid) return <div className="panel panel-pad" role="alert"><h2>Owner access required</h2><p>Assistant setup requires a literal installation superuser signed in as a person.</p></div>;
17
+ return <OwnerSetup key={`${auth.uid}:${revision}`} ownerId={me.id} revision={revision} />;
18
+ }
19
+ function KeySummary({ label, value, check }: { label: string; value: SetupKey; check: SetupCheck }) {
20
+ return <div className="assistant-setup-key-status"><p><strong>{label} source:</strong> {value.source === 'admin' ? 'Protected Admin storage' : value.source === 'deployment' ? 'Deployment configuration' : 'Not configured'}{value.hint && <span> · server hint: <code>{value.hint}</code></span>}</p><p className="dim">Last stored-key check: {check.ok === null ? 'Not checked' : check.ok ? 'Accepted' : 'Failed'}{check.at && ` · ${check.at}`} · {check.message}</p></div>;
21
+ }
22
+ function OwnerSetup({ ownerId, revision }: { ownerId: number; revision: number }) {
23
+ const queryClient = useQueryClient();
24
+ const [state, setState] = useState<AssistantSetupState | null>(null);
25
+ const stateRef = useRef(state); stateRef.current = state;
26
+ const [draft, setDraft] = useState(''); const [handlerDraft, setHandlerDraft] = useState('');
27
+ const [modelDraft, setModelDraft] = useState('');
28
+ const [modeDraft, setModeDraft] = useState<'auto' | 'pinned' | ''>('');
29
+ const [before, setBefore] = useState(''); const [limit, setLimit] = useState('25');
30
+ const [pending, setPending] = useState(''); const [notice, setNotice] = useState(''); const [error, setError] = useState('');
31
+ const [confirmation, setConfirmation] = useState<{ message: string; accept: () => void; cancel: () => void } | null>(null);
32
+ const live = useRef(false); const operation = useRef<Operation | null>(null);
33
+ const platform = useRef<HTMLDetailsElement>(null); const platformTitle = useRef<HTMLElement>(null);
34
+ const validOwner = () => {
35
+ const auth = getAuthSnapshot();
36
+ const me = queryClient.getQueryData<Me>(['me', auth.uid]);
37
+ return live.current && getAuthSessionRevision() === revision && auth.authenticated && auth.uid === String(ownerId) && me?.id === ownerId && me.is_superuser === true;
38
+ };
39
+ const clearDrafts = () => { setDraft(''); setHandlerDraft(''); setBefore(''); setLimit('25'); setModelDraft(''); setModeDraft(''); };
40
+ const dispose = () => {
41
+ operation.current?.controller.abort(); operation.current = null;
42
+ clearDrafts(); setConfirmation(null); setPending(''); setState(null); stateRef.current = null; setNotice(''); setError('');
43
+ };
44
+ const guard = (op: Operation) => {
45
+ if (!validOwner() || operation.current !== op || op.controller.signal.aborted) throw new DOMException('Setup scope ended', 'AbortError');
46
+ };
47
+ const options = (op: Operation) => ({ signal: op.controller.signal, beforeSend: () => guard(op) });
48
+ const apply = (next: AssistantSetupState, op: Operation) => { guard(op); stateRef.current = next; setState(next); setModelDraft(next.model.capability?.mode === 'pinned' ? next.model.selected : ''); setModeDraft(next.model.capability?.mode === 'auto' || next.model.capability?.mode === 'pinned' ? next.model.capability.mode : ''); };
49
+ const begin = (label: string): Operation | null => {
50
+ if (!validOwner() || operation.current) return null;
51
+ const op = { controller: new AbortController(), revision }; operation.current = op; setPending(label); setNotice(''); setError(''); return op;
52
+ };
53
+ const finish = (op: Operation) => { if (operation.current === op) { operation.current = null; setPending(''); setConfirmation(null); } };
54
+ async function failure(err: unknown, op: Operation, mutation: boolean) {
55
+ if (!validOwner() || operation.current !== op || op.controller.signal.aborted) return;
56
+ if (isReauthRequired(err)) {
57
+ clearDrafts(); setConfirmation(null); setNotice('Sign in again, then re-enter any key and explicitly retry. Nothing is automatically replayed.');
58
+ // Step-up replaces the session. Never close over a secret for retry.
59
+ await requestFreshAuth(); return;
60
+ }
61
+ setError(assistantSetupCredentialRefusal(err) ?? (mutation ? 'The operation was refused or its outcome is unknown. Refreshing current configuration; no operation will be retried automatically.' : 'Assistant setup could not be loaded. Try Refresh setup.'));
62
+ if (mutation) {
63
+ try { apply(await getAssistantSetup(options(op)), op); }
64
+ catch { if (validOwner() && operation.current === op) { setState(null); stateRef.current = null; } }
65
+ }
66
+ }
67
+ async function load(probe?: 'models' | 'discovery') {
68
+ const op = begin(probe ? 'Checking…' : 'Loading setup…'); if (!op) return;
69
+ try {
70
+ const next = await getAssistantSetup(options(op), probe); apply(next, op);
71
+ if (probe === 'models') {
72
+ guard(op);
73
+ const refresh = next.model.capability?.refresh;
74
+ if (refresh?.ok === true) setNotice('Provider catalogue refreshed. Review the resolved runtime model below.');
75
+ else setError(refresh?.code === 'refresh_in_progress' ? 'A catalogue refresh is already in progress. Returned configuration is unchanged; try again later.' : refresh?.code === 'refresh_throttled' ? 'Catalogue refresh is temporarily limited. Returned configuration is unchanged; try again later.' : 'Provider catalogue refresh was not confirmed. Any returned cached model keeps its reported freshness; no newer model is confirmed.');
76
+ } else if (probe === 'discovery') { guard(op); setNotice('Discovery check completed. Review its verdict below.'); }
77
+ }
78
+ catch (err) { await failure(err, op, false); } finally { finish(op); }
79
+ }
80
+ function change(make: (current: AssistantSetupState) => SetupAction, label: string, confirm?: string, credential?: SetupTarget) {
81
+ if (!stateRef.current) return;
82
+ const op = begin(label); if (!op) return;
83
+ const run = async () => {
84
+ let action: SetupAction | null = null;
85
+ try {
86
+ guard(op); setConfirmation(null);
87
+ if (!stateRef.current) throw new Error('Reload setup.');
88
+ action = make(stateRef.current);
89
+ const result = await mutateAssistantSetup(action, options(op));
90
+ guard(op); apply(result.state, op);
91
+ if (credential === 'assistant') setDraft(''); if (credential === 'handler') setHandlerDraft('');
92
+ if (result.result) {
93
+ if (result.result.ok === true) setNotice(result.result.message);
94
+ else setError(result.result.message || 'The credential check failed.');
95
+ } else setNotice(result.queued !== undefined ? `Historical triage queued: ${result.queued}.` : result.revoked !== undefined ? `Connections disconnected: ${result.revoked}.` : result.reset !== undefined ? `Breakers reset: ${result.reset}. Emergency stops are unchanged.` : label === 'Activating policy…' ? 'Deployed policy acknowledged on this node. Fleet-wide agreement has not been established.' : 'Configuration saved.');
96
+ } catch (err) {
97
+ // Drop request-local candidate references before showing any fresh-auth UI.
98
+ if (action && 'api_key' in action) delete action.api_key;
99
+ if (action && 'handler_api_key' in action) delete action.handler_api_key;
100
+ action = null;
101
+ await failure(err, op, true);
102
+ } finally {
103
+ if (action && 'api_key' in action) delete action.api_key;
104
+ if (action && 'handler_api_key' in action) delete action.handler_api_key;
105
+ finish(op);
106
+ }
107
+ };
108
+ if (confirm) setConfirmation({ message: confirm, accept: () => { void run(); }, cancel: () => finish(op) });
109
+ else void run();
110
+ }
111
+ useEffect(() => {
112
+ live.current = true;
113
+ const revoke = () => { if (!validOwner()) dispose(); };
114
+ const offAuth = subscribeAuth(revoke); const offQuery = queryClient.getQueryCache().subscribe(revoke);
115
+ void load();
116
+ return () => { live.current = false; operation.current?.controller.abort(); operation.current = null; offAuth(); offQuery(); };
117
+ // The keyed boundary owns identity/revision. Ordinary token renewal preserves this instance.
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ }, []);
120
+ const locked = pending !== '';
121
+ const capability = state?.model.capability;
122
+ const modelEditable = capability?.editable === true && capability.policy_model === 'auto:sonnet' && capability.mode !== 'fixed' && (capability.restriction === '' || capability.restriction === 'deployment_pin');
123
+ const autoAvailable = modelEditable && capability?.restriction !== 'deployment_pin';
124
+ const restriction = !capability ? 'Runtime model selection is unavailable on this server. Upgrade the backend to select Automatic or a manual pin.'
125
+ : capability.restriction === 'fixed_policy' ? 'The deployed policy fixes the model. Model selection is read-only.'
126
+ : capability.restriction === 'provider_unsupported' ? 'This provider uses a deployment-owned model. Automatic Sonnet and manual selection are unavailable.'
127
+ : capability.restriction === 'policy_unavailable' ? 'The deployed model policy is unavailable. Model selection is read-only.'
128
+ : capability.restriction === 'deployment_pin' ? 'A deployment pin is active beneath owner overrides. You may save a manual pin; remove the deployment pin before choosing Automatic.'
129
+ : !modelEditable ? 'Runtime model selection is read-only for this deployment.' : '';
130
+
131
+ const saveKey = (target: SetupTarget) => change((current) => assistantSetupSave(current, target === 'assistant' ? { api_key: draft } : { handler_api_key: handlerDraft }), 'Checking and saving key…', undefined, target);
132
+ const clearKey = (target: SetupTarget) => change((current) => assistantSetupSave(current, target === 'assistant' ? { clear_api_key: true } : { clear_handler_api_key: true }), 'Clearing protected key…', 'Clear the protected Admin credential? A deployment credential may become active. Review the returned source after saving.', target);
133
+ const openPlatform = () => { if (platform.current) platform.current.open = true; platformTitle.current?.focus(); };
134
+ return <main className="assistant-setup" aria-labelledby="assistant-setup-title">
135
+ <header className="assistant-setup-header"><div><div className="eyebrow">Installation owner</div><h1 id="assistant-setup-title">AI Assistant setup</h1><p className="dim">Set up your AI Assistant and review the controls that keep it safe.</p></div><button type="button" className="btn" disabled={locked} onClick={() => void load()}>Refresh setup</button></header>
136
+ <div role="status" aria-live="polite">{notice || (pending && !confirmation ? pending : '')}</div>
137
+ {error && <div className="assistant-setup-warning" role="alert">{error}</div>}
138
+ {confirmation && <section className="panel panel-pad assistant-setup-confirm" role="group" aria-label="Confirm setup operation"><p>{confirmation.message}</p><div className="assistant-action-row"><button className="btn" type="button" autoFocus onClick={confirmation.cancel}>Cancel</button><button className="btn btn-primary" type="button" onClick={confirmation.accept}>Confirm operation</button></div></section>}
139
+ {state && <>
140
+ <section className="panel panel-pad"><div className="eyebrow">Basic setup</div><h2>{state.enabled && state.route.ready && !state.emergency_stop && state.assistant_installed && state.realtime_installed ? 'Ready to use' : 'Needs attention'}</h2><p>{assistantSetupReadiness(state)}</p>
141
+ <dl className="assistant-setup-facts"><div><dt>Provider</dt><dd>{state.route.provider === 'anthropic' ? 'Anthropic' : state.route.provider === 'bedrock' ? 'AWS Bedrock' : 'Unavailable'}</dd></div><div><dt>Resolved runtime model</dt><dd><code>{state.model.effective || state.route.model || 'Unavailable'}</code></dd></div><div><dt>Selected credential</dt><dd>{state.route.credential === 'admin' ? 'Assistant key' : state.route.credential === 'handler' ? 'Platform key' : state.route.credential === 'aws' ? 'AWS identity' : 'Unavailable'}</dd></div><div><dt>Deployment configuration</dt><dd>{state.route.ready ? 'Ready' : 'Needs attention'}</dd></div></dl>
142
+ <section className="assistant-setup-model" aria-labelledby="assistant-model-title">
143
+ <h3 id="assistant-model-title">Model selection</h3>
144
+ <p>Selection mode: <strong>{capability?.mode === 'auto' ? 'Automatic' : capability?.mode === 'pinned' ? 'Manual pin' : capability?.mode === 'fixed' ? 'Fixed by deployment' : 'Unavailable'}</strong> · source: {capability ? state.model.source : state.model.source || 'Unavailable'}</p>
145
+ {restriction && <p className="assistant-setup-warning" id="assistant-model-restriction">{restriction}</p>}
146
+ {modelEditable && <>
147
+ <label className="assistant-setup-field" htmlFor="assistant-model-mode">Selection method<select className="input" id="assistant-model-mode" value={modeDraft} disabled={locked} onChange={(event) => setModeDraft(event.target.value as 'auto' | 'pinned')} aria-describedby="assistant-model-help">
148
+ {!modeDraft && <option value="" disabled>Choose selection method</option>}<option value="auto" disabled={!autoAvailable}>Automatic — latest Sonnet</option><option value="pinned">Manual pin</option>
149
+ </select></label>
150
+ {modeDraft === 'pinned' && <label className="assistant-setup-field" htmlFor="assistant-model-pin">Manual model identifier<input className="input" id="assistant-model-pin" value={modelDraft} onChange={(event) => setModelDraft(event.target.value)} disabled={locked} list="assistant-model-choices" maxLength={81} autoComplete="off" spellCheck={false} /></label>}
151
+ <datalist id="assistant-model-choices">{state.model.choices.map((choice) => <option key={choice.id} value={choice.id}>{choice.label}</option>)}</datalist>
152
+ <p className="dim" id="assistant-model-help">Automatic lets the backend resolve a compatible Sonnet from its provider catalogue. A manual pin takes effect only after saving. Refreshing cannot replace a manual pin.</p>
153
+ <div className="assistant-action-row"><button className="btn btn-primary" type="button" disabled={locked || !modeDraft || (modeDraft === 'auto' ? !autoAvailable : !/^[a-z0-9][a-z0-9.\-]{0,80}$/.test(modelDraft))} onClick={() => change(() => modeDraft === 'auto' ? { action: 'set_model_selection', mode: 'auto' } : { action: 'set_model_selection', mode: 'pinned', model: modelDraft }, 'Saving model selection…')}>Save model selection</button><button className="btn" type="button" disabled={locked} onClick={() => void load('models')}>Refresh provider catalogue</button></div>
154
+ </>}
155
+ {capability && <div className="assistant-setup-catalogue" aria-label="Provider catalogue status">
156
+ <p>Catalogue: <strong>{capability.discovery.status === 'fresh' ? 'Fresh cache' : capability.discovery.status === 'stale' ? 'Stale cache — cached resolution only' : capability.discovery.status === 'unavailable' ? 'Unavailable — no usable automatic resolution' : 'Not applicable to the selected mode'}</strong>{capability.discovery.error_code && ' · The backend reports a discovery problem.'}</p>
157
+ <dl className="assistant-setup-facts"><div><dt>Last successful fetch</dt><dd>{capability.discovery.fetched_at || 'None reported'}</dd></div><div><dt>Last attempt</dt><dd>{capability.discovery.attempted_at || 'None reported'}</dd></div><div><dt>Fresh until</dt><dd>{capability.discovery.expires_at || 'Not applicable'}</dd></div><div><dt>Usable cached limit</dt><dd>{capability.discovery.max_stale_at || 'Not applicable'}</dd></div></dl>
158
+ </div>}
159
+ </section>
160
+ {state.route.credential === 'handler' && <div className="assistant-setup-warning"><p>This deployed route uses the Platform credential. An Assistant key will not supply it.</p><button className="btn" type="button" onClick={openPlatform}>Open Platform credential</button></div>}
161
+ {state.route.credential === 'aws' && <p>The Bedrock route uses deployment AWS identity. Browser key fields do not configure that identity.</p>}
162
+ <KeySummary label="Assistant key" value={state.key} check={state.verify} />
163
+ <label className="assistant-setup-field" htmlFor="assistant-replacement-key">Replacement Assistant key<input id="assistant-replacement-key" className="input" type="password" value={draft} onChange={(event) => setDraft(event.target.value)} autoComplete="new-password" spellCheck={false} maxLength={4096} disabled={locked} aria-describedby="assistant-key-help" /></label>
164
+ <p className="dim" id="assistant-key-help">Write-only replacement. The server checks a new key before storing it. Nothing saves while you type.</p>
165
+ <div className="assistant-action-row"><button type="button" className="btn btn-primary" disabled={locked || !draft.trim()} onClick={() => saveKey('assistant')}>Check and save key</button><button type="button" className="btn" disabled={locked} onClick={() => change(() => ({ action: 'verify', target: 'assistant' }), 'Checking current key…')}>Check current key</button><button type="button" className="btn" disabled={locked || state.key.source !== 'admin'} onClick={() => clearKey('assistant')}>Clear stored Assistant key</button></div>
166
+ <div className="assistant-setup-switch"><span>Assistant is <strong>{state.enabled ? 'enabled' : 'disabled'}</strong>. Enabling does not override deployment policy or emergency stops.</span><button className="btn" type="button" disabled={locked} onClick={() => change((current) => assistantSetupSave(current, { enabled: !current.enabled }), 'Saving Assistant switch…')}>{state.enabled ? 'Disable Assistant' : 'Enable Assistant'}</button></div>
167
+ </section>
168
+ <p className="dim">Advanced controls · deployment, safety, remote access and incident processing</p>
169
+ <details className="panel assistant-setup-advanced" ref={platform}><summary ref={platformTitle} tabIndex={0}>Platform credential</summary><div className="panel-pad">
170
+ <p>Used only by deployed routes selecting <code>handler</code>. Credentials never substitute for one another.</p><KeySummary label="Platform key" value={state.handler_key} check={state.handler_verify} />
171
+ <label className="assistant-setup-field" htmlFor="handler-replacement-key">Replacement Platform key<input id="handler-replacement-key" className="input" type="password" value={handlerDraft} onChange={(event) => setHandlerDraft(event.target.value)} autoComplete="new-password" spellCheck={false} maxLength={4096} disabled={locked} /></label>
172
+ <div className="assistant-action-row"><button className="btn btn-primary" type="button" disabled={locked || !handlerDraft.trim()} onClick={() => saveKey('handler')}>Check and save Platform key</button><button className="btn" type="button" disabled={locked} onClick={() => change(() => ({ action: 'verify', target: 'handler' }), 'Checking Platform key…')}>Check current Platform key</button><button className="btn" type="button" disabled={locked || state.handler_key.source !== 'admin'} onClick={() => clearKey('handler')}>Clear stored Platform key</button></div>
173
+ </div></details>
174
+ <details className="panel assistant-setup-advanced"><summary>Deployed policy and safety</summary><div className="panel-pad">
175
+ {capability && <p className="dim">Deployed policy model: <code>{capability.policy_model || 'Unavailable'}</code></p>}
176
+ {state.route.can_invoke !== undefined && <dl className="assistant-setup-facts"><div><dt>Guarded request admission</dt><dd>{state.route.can_invoke ? 'Allowed to attempt' : 'Blocked'} · separate from configuration readiness</dd></div></dl>}
177
+ <p>Policy status: {state.route.error || 'Ready'}. Deployment owns policy content and the runtime route. Activation acknowledges the policy on this node; it cannot establish fleet-wide agreement.</p><button type="button" className="btn" disabled={locked} onClick={() => change(() => ({ action: 'activate_policy' }), 'Activating policy…', 'Acknowledge the currently deployed policy on this node? This does not edit policy content, check other nodes or release an emergency stop.')}>Acknowledge deployed policy</button>
178
+ <dl className="assistant-setup-facts"><div><dt>Deployment stop (read-only)</dt><dd>{state.emergency_stop_static ? 'On' : 'Off'}</dd></div><div><dt>Database stop</dt><dd>{state.emergency_stop_database ? 'On' : 'Off'}</dd></div><div><dt>Effective stop</dt><dd>{state.emergency_stop ? 'On' : 'Off'}</dd></div></dl>
179
+ <button type="button" className="btn" disabled={locked} onClick={() => change((current) => assistantSetupSave(current, { emergency_stop: !current.emergency_stop_database }), 'Saving database stop…', 'Change only the database emergency stop? A deployment stop remains authoritative.')}>{state.emergency_stop_database ? 'Release database stop' : 'Set database stop'}</button>
180
+ <h3>Requests · last {state.safety.hours || 24} hours</h3>{state.safety.error && <p role="alert">Safety statistics unavailable.</p>}
181
+ <div className="assistant-table-scroll"><table><caption>Guarded request aggregates</caption><thead><tr><th>Provider / feature</th><th>Status</th><th>Requests</th><th>Tokens in / out</th></tr></thead><tbody>{state.safety.requests.map((row, i) => <tr key={i}><td>{row.provider} / {row.feature}</td><td>{row.status}</td><td>{row.requests}</td><td>{row.input_tokens} / {row.output_tokens}</td></tr>)}</tbody></table></div>
182
+ <h3>Circuit breakers</h3><div className="assistant-table-scroll"><table><caption>Breaker state by provider</caption><thead><tr><th>Provider</th><th>State</th><th>Error</th><th>Count</th></tr></thead><tbody>{state.safety.breakers.map((row, i) => <tr key={i}><td>{row.provider}</td><td>{row.state}</td><td>{row.error_code || 'None'}</td><td>{row.count}</td></tr>)}</tbody></table></div>
183
+ <div className="assistant-action-row">{(['all', 'anthropic', 'bedrock'] as const).map((provider) => <button key={provider} type="button" className="btn" disabled={locked} onClick={() => change(() => ({ action: 'reset_breaker', ...(provider === 'all' ? {} : { provider }) }), 'Resetting breakers…', `Reset ${provider} circuit breakers? Requests may try again; policy and emergency stops remain enforced.`)}>Reset {provider} breakers</button>)}</div>
184
+ </div></details>
185
+ <details className="panel assistant-setup-advanced"><summary>Remote agent access and connections</summary><div className="panel-pad"><p>Remote agents can receive Tools, Full API or both kinds of access according to their grants. Full API access carries the signed-in user’s API authority. Turning access off pauses connections without revoking them.</p>
186
+ <p>Remote access is <strong>{state.mcp.enabled ? 'on' : 'off'}</strong>.</p><button type="button" className="btn" disabled={locked} onClick={() => change((current) => assistantSetupSave(current, { mcp_enabled: !current.mcp.enabled }), 'Saving remote access…', 'Change remote agent access? Existing grants are retained; Disconnect revokes them.')}>{state.mcp.enabled ? 'Pause remote access' : 'Enable remote access'}</button>
187
+ <label className="assistant-setup-field" htmlFor="assistant-connect-address">Connect address (select to copy)<input id="assistant-connect-address" className="input" value={state.mcp.url} readOnly onFocus={(event) => event.target.select()} /></label><p className="dim">{state.mcp.discovery_url || 'No public discovery address configured.'}</p>
188
+ <button type="button" className="btn" disabled={locked} onClick={() => void load('discovery')}>Check public discovery</button><p role={state.mcp.discovery.ok === false ? 'alert' : undefined}>Discovery: {state.mcp.discovery.ok === null ? 'Not checked' : state.mcp.discovery.ok ? 'Reachable' : 'Failed'} · {state.mcp.discovery.detail}{state.mcp.discovery.checked_at && ` · ${state.mcp.discovery.checked_at}`}</p>
189
+ <h3>Connected agents · {state.mcp.grant_count} total</h3><p className="dim">Showing {state.mcp.grants.length} connections, capped at 200. Disconnect all spans Tools and Full API grants for every user.</p><div className="assistant-table-scroll"><table><caption>Remote connections</caption><thead><tr><th>Client / user</th><th>Access</th><th>Last used / expires</th><th>Connection</th></tr></thead><tbody>{state.mcp.grants.map((row) => <tr key={row.id}><td>{row.client.name || row.client.client_id}<br />{row.user.display_name || row.user.email}</td><td>{row.access === 'api' ? 'Full API' : row.access === 'both' ? 'Tools and Full API' : 'Tools'}</td><td>{row.last_used || 'Not used'}<br />{row.expires}</td><td><button type="button" className="btn" disabled={locked} onClick={() => change(() => ({ action: 'revoke_grant', grant_id: row.id }), 'Disconnecting agent…', `Disconnect ${row.client.name || 'this agent'} for ${row.user.display_name || row.user.email}? A new authorization is required to reconnect.`)}>Disconnect #{row.id}</button></td></tr>)}</tbody></table></div><button type="button" className="btn" disabled={locked || state.mcp.grant_count === 0} onClick={() => change(() => ({ action: 'revoke_all_grants' }), 'Disconnecting all agents…', 'Disconnect every remote Tools and Full API grant, for every user? New authorizations will be required.')}>Disconnect all agents</button>
190
+ </div></details>
191
+ <details className="panel assistant-setup-advanced"><summary>Incident triage</summary><div className="panel-pad"><p>Autonomous incident triage is <strong>{state.autonomous_triage ? 'on' : 'off'}</strong>. Enabling establishes a no-history watermark; older incidents need an explicit historical run.</p><p>Watermark: {state.autonomous_triage_activated_at || 'Not activated'}</p><button type="button" className="btn" disabled={locked} onClick={() => change((current) => assistantSetupSave(current, { autonomous_triage: !current.autonomous_triage }), 'Saving autonomous triage…', 'Change autonomous incident triage? New activation establishes a watermark; it does not queue older incidents.')}>{state.autonomous_triage ? 'Disable autonomous triage' : 'Enable autonomous triage'}</button>
192
+ <h3>Historical incident triage</h3><label className="assistant-setup-field" htmlFor="assistant-triage-before">Incidents before (local time)<input className="input" id="assistant-triage-before" type="datetime-local" value={before} onChange={(event) => setBefore(event.target.value)} disabled={locked} /></label><label className="assistant-setup-field" htmlFor="assistant-triage-limit">Maximum incidents (1–100)<input className="input" id="assistant-triage-limit" type="number" min={1} max={100} step={1} value={limit} onChange={(event) => setLimit(event.target.value)} disabled={locked} /></label><button type="button" className="btn" disabled={locked || !before || !Number.isInteger(Number(limit)) || Number(limit) < 1 || Number(limit) > 100} onClick={() => change(() => ({ action: 'historical_triage', before: new Date(before).toISOString(), limit: Number(limit) }), 'Queueing historical triage…', `Queue up to ${limit} incidents before ${before}? This explicitly processes historical incidents and can incur provider cost.`)}>Queue historical triage</button>
193
+ </div></details>
194
+ </>}
195
+ </main>;
196
+ }
@@ -9,3 +9,5 @@ export * from './pages';
9
9
  export * from './streaming';
10
10
 
11
11
  export { ASSISTANT_ADMIN_SECTION } from '../domains/assistant';
12
+ export * from './setup';
13
+ export * from './AssistantSetupPage';