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.
@@ -0,0 +1,174 @@
1
+ /** Owner-only, imperative setup protocol. Never put these operations in Query/Mutation caches. */
2
+ import { apiOrigin, mojoCall, usingMockTransport, type FetchOpts } from '../../client/client';
3
+
4
+ export const ASSISTANT_SETUP_ENDPOINT = '/api/account/admin/assistant';
5
+ export type SetupTarget = 'assistant' | 'handler';
6
+ export interface SetupKey { configured: boolean; source: 'admin' | 'deployment' | 'none'; hint: string }
7
+ export interface SetupCheck { ok: boolean | null; code: string; message: string; at: string }
8
+ export interface SetupGrant {
9
+ id: number; client: { id: number; client_id: string; name: string };
10
+ user: { id: number; email: string; display_name: string }; resource: string; scopes: string[];
11
+ access: 'tools' | 'api' | 'both'; created: string; last_used: string; expires: string; is_active: boolean; revoked_reason: string;
12
+ }
13
+ export interface AssistantSetupState {
14
+ schema_version: 2; enabled: boolean; key: SetupKey; handler_key: SetupKey;
15
+ model: { selected: string; effective: string; source: string; choices: Array<{ id: string; label: string }>; capability?: {
16
+ mode: 'auto' | 'pinned' | 'fixed'; editable: boolean; policy_model: string;
17
+ restriction: '' | 'fixed_policy' | 'provider_unsupported' | 'deployment_pin' | 'policy_unavailable';
18
+ discovery: { status: 'fresh' | 'stale' | 'unavailable' | 'not_applicable'; fetched_at: string | null; attempted_at: string | null; expires_at: string | null; max_stale_at: string | null; error_code: string };
19
+ refresh: { attempted: boolean; ok: boolean | null; code: string };
20
+ } };
21
+ verify: SetupCheck; handler_verify: SetupCheck;
22
+ emergency_stop: boolean; emergency_stop_static: boolean; emergency_stop_database: boolean;
23
+ route: { feature: string; provider: string; model: string; credential: string; credential_configured: boolean; ready: boolean; can_invoke?: boolean; error: string };
24
+ autonomous_triage: boolean; autonomous_triage_activated_at: string;
25
+ assistant_installed: boolean; realtime_installed: boolean;
26
+ mcp: { enabled: boolean; path: string; url: string; discovery_url: string;
27
+ discovery: { ok: boolean | null; code: string; detail: string; checked_at: string }; grants: SetupGrant[]; grant_count: number };
28
+ safety: { hours: number; error: string; requests: Array<{ provider: string; feature: string; status: string; requests: number; input_tokens: number; output_tokens: number }>;
29
+ breakers: Array<{ provider: string; state: string; error_code: string; count: number }> };
30
+ }
31
+ export interface SetupSave {
32
+ action: 'save'; enabled: boolean; model?: string; api_key?: string; clear_api_key?: boolean;
33
+ handler_api_key?: string; clear_handler_api_key?: boolean; mcp_enabled?: boolean; emergency_stop?: boolean; autonomous_triage?: boolean;
34
+ }
35
+ export type SetupAction = SetupSave
36
+ | { action: 'set_model_selection'; mode: 'auto'; model?: never }
37
+ | { action: 'set_model_selection'; mode: 'pinned'; model: string }
38
+ | { action: 'verify'; target: SetupTarget; api_key?: string }
39
+ | { action: 'activate_policy' }
40
+ | { action: 'reset_breaker'; provider?: 'anthropic' | 'bedrock' }
41
+ | { action: 'historical_triage'; before: string; limit: number }
42
+ | { action: 'revoke_grant'; grant_id: number }
43
+ | { action: 'revoke_all_grants' };
44
+ export interface SetupActionResult { state: AssistantSetupState; result?: SetupCheck; saved?: boolean; verified?: boolean; activated?: boolean; reset?: number; revoked?: number; queued?: number }
45
+ const object = (v: unknown): Record<string, unknown> => v != null && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {};
46
+ const text = (v: unknown, max = 160): string => typeof v === 'string' ? v.slice(0, max) : '';
47
+ const number = (v: unknown): number => typeof v === 'number' && Number.isFinite(v) ? Math.max(0, v) : 0;
48
+ const verdict = (v: unknown): boolean | null => typeof v === 'boolean' ? v : null;
49
+ const rows = (v: unknown, max: number): Record<string, unknown>[] => Array.isArray(v) ? v.slice(0, max).map(object) : [];
50
+ const identifier = (v: unknown): string => /^[a-z0-9][a-z0-9.\-]{0,80}$/.test(text(v, 82)) ? text(v, 81) : '';
51
+ // The additive wire capability is all-or-nothing; malformed fields never enable a writer.
52
+ const runtimeIdentifier = (v: unknown): string => typeof v === 'string' && v.length <= 200 && /^[^\s\x00-\x1f\x7f]+$/.test(v) ? v : '';
53
+ const safeCode = (v: unknown): string => typeof v === 'string' && /^[a-z0-9_]{0,64}$/.test(v) ? v : '';
54
+ function projectModelCapability(model: Record<string, unknown>): AssistantSetupState['model']['capability'] {
55
+ const d = object(model.discovery), r = object(model.refresh);
56
+ const timestamp = (v: unknown) => v === null || (typeof v === 'string' && v.length <= 40 && /^\d{4}-\d\d-\d\dT/.test(v) && Number.isFinite(Date.parse(v)));
57
+ if (typeof model.mode !== 'string' || typeof model.restriction !== 'string' || typeof model.source !== 'string' || typeof d.status !== 'string' || !['auto', 'pinned', 'fixed'].includes(model.mode) || typeof model.editable !== 'boolean'
58
+ || typeof model.policy_model !== 'string' || model.policy_model.length > 200
59
+ || !['', 'fixed_policy', 'provider_unsupported', 'deployment_pin', 'policy_unavailable'].includes(model.restriction)
60
+ || !['automatic', 'admin', 'deployment', 'policy'].includes(model.source)
61
+ || !['fresh', 'stale', 'unavailable', 'not_applicable'].includes(d.status)
62
+ || !['fetched_at', 'attempted_at', 'expires_at', 'max_stale_at'].every((key) => timestamp(d[key]))
63
+ || typeof d.error_code !== 'string' || safeCode(d.error_code) !== d.error_code
64
+ || typeof r.attempted !== 'boolean' || (r.ok !== null && typeof r.ok !== 'boolean')
65
+ || typeof r.code !== 'string' || safeCode(r.code) !== r.code) return undefined;
66
+ if (model.mode === 'fixed' ? model.editable !== false || !['fixed_policy', 'provider_unsupported', 'policy_unavailable'].includes(model.restriction)
67
+ : model.editable !== true || model.policy_model !== 'auto:sonnet' || !['', 'deployment_pin'].includes(model.restriction)
68
+ || (model.mode === 'auto' ? model.source !== 'automatic' || model.restriction !== '' : !['admin', 'deployment'].includes(model.source) || !identifier(model.selected))) return undefined;
69
+ return {
70
+ mode: model.mode as 'auto' | 'pinned' | 'fixed', editable: model.editable, policy_model: model.policy_model,
71
+ restriction: model.restriction as NonNullable<AssistantSetupState['model']['capability']>['restriction'],
72
+ discovery: { status: d.status as 'fresh' | 'stale' | 'unavailable' | 'not_applicable', fetched_at: d.fetched_at as string | null, attempted_at: d.attempted_at as string | null, expires_at: d.expires_at as string | null, max_stale_at: d.max_stale_at as string | null, error_code: d.error_code },
73
+ refresh: { attempted: r.attempted, ok: r.ok as boolean | null, code: r.code },
74
+ };
75
+ }
76
+ const CHECK_MESSAGES: Record<string, string> = { verified: 'Anthropic accepted this key.', invalid_key: 'Anthropic rejected this key.', unreachable: 'Anthropic could not be reached to check this key.', not_configured: 'No API key is configured to check.' };
77
+ function projectCheck(raw: unknown): SetupCheck {
78
+ const v = object(raw); const code = text(v.code, 64);
79
+ return { ok: verdict(v.ok), code, message: CHECK_MESSAGES[code] ?? 'No stored check is available.', at: text(v.at, 40) };
80
+ }
81
+ function projectKey(raw: unknown): SetupKey {
82
+ const v = object(raw);
83
+ return { configured: v.configured === true, source: v.source === 'admin' || v.source === 'deployment' ? v.source : 'none', hint: text(v.hint, 32) };
84
+ }
85
+ /** Positive bounded schema projection. Raw response objects never enter component state. */
86
+ export function projectAssistantSetup(raw: unknown): AssistantSetupState {
87
+ const v = object(raw);
88
+ if (v.schema_version !== 2) throw new Error('Assistant setup schema is unavailable.');
89
+ const model = object(v.model), route = object(v.route), mcp = object(v.mcp), discovery = object(mcp.discovery), safety = object(v.safety);
90
+ const capability = projectModelCapability(model);
91
+ return {
92
+ schema_version: 2, enabled: v.enabled === true, key: projectKey(v.key), handler_key: projectKey(v.handler_key),
93
+ model: { selected: identifier(model.selected), effective: text(model.effective, 200), source: ['automatic', 'admin', 'deployment', 'policy', 'setting', 'default'].includes(String(model.source)) ? String(model.source) : '', choices: rows(model.choices, 200).map((row) => ({ id: runtimeIdentifier(row.id), label: text(row.label) })).filter((row) => row.id !== ''), ...(capability ? { capability } : {}) },
94
+ verify: projectCheck(v.verify), handler_verify: projectCheck(v.handler_verify),
95
+ emergency_stop: v.emergency_stop !== false, emergency_stop_static: v.emergency_stop_static !== false, emergency_stop_database: v.emergency_stop_database !== false,
96
+ route: { feature: text(route.feature, 64), provider: ['anthropic', 'bedrock'].includes(String(route.provider)) ? String(route.provider) : '', model: text(route.model, 200), credential: ['admin', 'handler', 'aws'].includes(String(route.credential)) ? String(route.credential) : '', credential_configured: route.credential_configured === true, ready: route.ready === true, ...(typeof route.can_invoke === 'boolean' ? { can_invoke: route.can_invoke } : {}), error: text(route.error, 64) },
97
+ autonomous_triage: v.autonomous_triage === true, autonomous_triage_activated_at: text(v.autonomous_triage_activated_at, 40),
98
+ assistant_installed: v.assistant_installed === true, realtime_installed: v.realtime_installed === true,
99
+ mcp: { enabled: mcp.enabled === true, path: text(mcp.path, 500), url: text(mcp.url, 2000), discovery_url: text(mcp.discovery_url, 2000),
100
+ discovery: { ok: verdict(discovery.ok), code: text(discovery.code, 64), detail: text(discovery.detail, 500), checked_at: text(discovery.checked_at, 40) }, grant_count: number(mcp.grant_count),
101
+ grants: rows(mcp.grants, 200).map((row): SetupGrant => { const client = object(row.client), user = object(row.user); return {
102
+ id: number(row.id), client: { id: number(client.id), client_id: text(client.client_id), name: text(client.name) },
103
+ user: { id: number(user.id), email: text(user.email, 254), display_name: text(user.display_name) }, resource: text(row.resource, 2000),
104
+ scopes: Array.isArray(row.scopes) ? row.scopes.filter((s): s is string => typeof s === 'string').slice(0, 30).map((s) => s.slice(0, 64)) : [],
105
+ access: row.access === 'api' || row.access === 'both' ? row.access : 'tools', created: text(row.created, 40), last_used: text(row.last_used, 40), expires: text(row.expires, 40), is_active: row.is_active === true, revoked_reason: text(row.revoked_reason, 160),
106
+ }; }).filter((row) => Number.isSafeInteger(row.id) && row.id > 0) },
107
+ safety: { hours: number(safety.hours), error: text(safety.error, 64),
108
+ requests: rows(safety.requests, 200).map((row) => ({ provider: text(row.provider, 64), feature: text(row.feature, 64), status: text(row.status, 64), requests: number(row.requests), input_tokens: number(row.input_tokens), output_tokens: number(row.output_tokens) })),
109
+ breakers: rows(safety.breakers, 100).map((row) => ({ provider: text(row.provider, 64), state: text(row.state, 64), error_code: text(row.error_code, 64), count: number(row.count) })) },
110
+ };
111
+ }
112
+ export function assistantSetupReadiness(state: AssistantSetupState): string {
113
+ if (!state.assistant_installed) return 'The Assistant application is not installed.';
114
+ if (!state.realtime_installed) return 'The realtime application is not installed. Install it before using the Assistant.';
115
+ if (state.emergency_stop) return 'An emergency stop is blocking Assistant requests.';
116
+ if (state.model.capability?.mode === 'auto' && state.route.provider && state.route.credential && !state.model.effective) return 'Automatic model resolution is unavailable. Refresh the provider catalogue and review its reported freshness. Request admission does not prove that a usable model is ready.';
117
+ if (!state.route.provider || !state.route.model || !state.route.credential) return 'The deployment’s model and key selection are unavailable. Review the deployed policy and its selected credential in advanced controls; adding an Assistant key alone may not resolve this.';
118
+ if (!state.route.ready) return 'The deployment configuration needs attention. Review its selected key and policy.';
119
+ if (!state.enabled) return 'The deployment configuration is ready. Enable the Assistant when you are ready.';
120
+ return 'Assistant configuration is ready. Provider availability has not been checked.';
121
+ }
122
+ export function assertAssistantSetupOrigin(): void {
123
+ if (usingMockTransport()) return;
124
+ if (new URL(apiOrigin(), window.location.origin).origin !== window.location.origin) throw new Error('Assistant setup actions require the same origin.');
125
+ }
126
+ export type SetupRequestOptions = Pick<FetchOpts, 'signal' | 'beforeSend'>;
127
+ export async function getAssistantSetup(options: SetupRequestOptions = {}, probe?: 'models' | 'discovery'): Promise<AssistantSetupState> {
128
+ const body = await mojoCall(ASSISTANT_SETUP_ENDPOINT, { ...options, params: probe === 'models' ? { refresh: 'models' } : probe === 'discovery' ? { check: 'discovery' } : undefined });
129
+ return projectAssistantSetup(body.data);
130
+ }
131
+ const ACTION_KEYS: Record<SetupAction['action'], readonly string[]> = {
132
+ save: ['action', 'enabled', 'model', 'api_key', 'clear_api_key', 'handler_api_key', 'clear_handler_api_key', 'mcp_enabled', 'emergency_stop', 'autonomous_triage'],
133
+ set_model_selection: ['action', 'mode', 'model'],
134
+ verify: ['action', 'target', 'api_key'], activate_policy: ['action'], reset_breaker: ['action', 'provider'], historical_triage: ['action', 'before', 'limit'], revoke_grant: ['action', 'grant_id'], revoke_all_grants: ['action'],
135
+ };
136
+ export function validateAssistantSetupAction(action: SetupAction): void {
137
+ if (!ACTION_KEYS[action.action] || Object.keys(action).some((key) => !ACTION_KEYS[action.action].includes(key))) throw new Error('Unsupported setup action fields.');
138
+ const v = action as unknown as Record<string, unknown>;
139
+ for (const key of ['api_key', 'handler_api_key']) if (v[key] !== undefined && (typeof v[key] !== 'string' || (v[key] as string).length > 4096 || /\s/.test(v[key] as string))) throw new Error('Enter a valid key without spaces.');
140
+ if (action.action === 'save') {
141
+ if (typeof action.enabled !== 'boolean' || (action.model !== undefined && (typeof action.model !== 'string' || (action.model !== '' && identifier(action.model) !== action.model)))) throw new Error('Enabled and a valid optional legacy model are required.');
142
+ for (const key of ['clear_api_key', 'clear_handler_api_key', 'mcp_enabled', 'emergency_stop', 'autonomous_triage']) if (v[key] !== undefined && typeof v[key] !== 'boolean') throw new Error('Setup switches must be boolean.');
143
+ if ((action.api_key && action.clear_api_key) || (action.handler_api_key && action.clear_handler_api_key)) throw new Error('Replace and clear are separate operations.');
144
+ }
145
+ if (action.action === 'set_model_selection' && (action.mode === 'auto' ? Object.hasOwn(action, 'model') : action.mode !== 'pinned' || !action.model || identifier(action.model) !== action.model)) throw new Error('Choose Automatic without a model or a valid manual pin.');
146
+ if (action.action === 'verify' && action.target !== 'assistant' && action.target !== 'handler') throw new Error('Select a credential target.');
147
+ if (action.action === 'reset_breaker' && action.provider !== undefined && !['anthropic', 'bedrock'].includes(action.provider)) throw new Error('Unsupported provider.');
148
+ if (action.action === 'historical_triage' && (!/^\d{4}-\d\d-\d\dT/.test(action.before) || !Number.isFinite(Date.parse(action.before)) || !Number.isInteger(action.limit) || action.limit < 1 || action.limit > 100)) throw new Error('Choose an ISO cutoff and a limit from 1 through 100.');
149
+ if (action.action === 'revoke_grant' && (!Number.isSafeInteger(action.grant_id) || action.grant_id <= 0)) throw new Error('Select a valid connection.');
150
+ }
151
+ export async function mutateAssistantSetup(action: SetupAction, options: SetupRequestOptions = {}): Promise<SetupActionResult> {
152
+ validateAssistantSetupAction(action); assertAssistantSetupOrigin();
153
+ const body = await mojoCall(ASSISTANT_SETUP_ENDPOINT, { method: 'POST', body: { ...action }, signal: options.signal, beforeSend: () => { assertAssistantSetupOrigin(); options.beforeSend?.(); } });
154
+ const raw = object(body.data);
155
+ if (raw.schema_version !== 2) throw new Error('Assistant setup schema is unavailable.');
156
+ return { state: projectAssistantSetup(raw.state), ...(action.action === 'verify' ? { result: projectCheck(raw.result) } : {}),
157
+ ...(typeof raw.saved === 'boolean' ? { saved: raw.saved } : {}), ...(typeof raw.verified === 'boolean' ? { verified: raw.verified } : {}), ...(typeof raw.activated === 'boolean' ? { activated: raw.activated } : {}),
158
+ ...(typeof raw.reset === 'number' ? { reset: number(raw.reset) } : {}), ...(typeof raw.revoked === 'number' ? { revoked: number(raw.revoked) } : {}), ...(typeof raw.queued === 'number' ? { queued: number(raw.queued) } : {}) };
159
+ }
160
+ /** Preserve old-server baselines; capable servers own model selection through its dedicated action. */
161
+ export function assistantSetupSave(state: AssistantSetupState, changes: Omit<SetupSave, 'action' | 'enabled' | 'model'> & { enabled?: boolean; model?: string }): SetupSave {
162
+ const { model, ...other } = changes;
163
+ return { action: 'save', enabled: state.enabled, ...(!state.model.capability ? { model: model ?? state.model.selected } : {}), ...other };
164
+ }
165
+
166
+ /** Only backend fixed pre-write credential refusals earn a definite no-write message. */
167
+ export function assistantSetupCredentialRefusal(error: unknown): string | null {
168
+ if (!(error instanceof Error)) return null;
169
+ for (const [wire, label] of [['api key', 'Assistant key'], ['handler api key', 'Platform key']] as const) {
170
+ if (error.message === `The ${wire} was not accepted. Anthropic rejected this key.`) return `The ${label} was rejected. Check the replacement and try again. Nothing was stored.`;
171
+ if (error.message === `The ${wire} was not accepted. Anthropic could not be reached to check this key.`) return `The ${label} could not be checked because Anthropic was unreachable. Nothing was stored; try again when the provider is available.`;
172
+ }
173
+ return null;
174
+ }
@@ -40,6 +40,8 @@ type AdminRouteBase = {
40
40
  permissions?: PermSpec;
41
41
  label?: string;
42
42
  fallbackToFirstVisible?: boolean;
43
+ /** Literal installation owner; permission and group grants cannot satisfy it. */
44
+ requireSuperuser?: boolean;
43
45
  };
44
46
 
45
47
  /** External routes may stay synchronous; packaged routes use the explicit lazy arm. */
@@ -114,6 +116,11 @@ function AdminDenied() {
114
116
  createElement('p', { className: 'dim' }, 'Your account does not have permission to open this admin page.')));
115
117
  }
116
118
 
119
+ function AdminOwnerOnly({ children }: { children: ReactNode }) {
120
+ const { data: me } = useMe();
121
+ return me?.is_superuser === true ? children : createElement(AdminDenied);
122
+ }
123
+
117
124
  function routeElement(route: AdminRoute): ReactNode {
118
125
  return 'loadComponent' in route
119
126
  ? createElement(AdminLazyPage, { load: route.loadComponent! })
@@ -124,7 +131,9 @@ function guardedElement(section: AdminSection, route: AdminRoute, sections: read
124
131
  const fallback = route.fallbackToFirstVisible
125
132
  ? createElement(AdminMountLanding, { sections: sections.filter((candidate) => candidate !== section), mount })
126
133
  : createElement(AdminDenied);
127
- const page = routeElement(route);
134
+ const page = route.requireSuperuser
135
+ ? createElement(AdminOwnerOnly, { children: routeElement(route) })
136
+ : routeElement(route);
128
137
  const routeGuarded = route.permissions
129
138
  ? createElement(Guarded, { permission: route.permissions, fallback, children: page })
130
139
  : page;
@@ -150,7 +159,8 @@ export function AdminGlobalScope({ children }: { children: ReactNode }) {
150
159
  }
151
160
 
152
161
  function routeVisible(section: AdminSection, route: AdminRoute, me: ReturnType<typeof useMe>['data']): boolean {
153
- return hasPermission(me ?? null, section.permissions, null)
162
+ return (!route.requireSuperuser || me?.is_superuser === true)
163
+ && hasPermission(me ?? null, section.permissions, null)
154
164
  && (!route.permissions || hasPermission(me ?? null, route.permissions, null));
155
165
  }
156
166
 
@@ -207,7 +217,7 @@ export function adminSectionsMenu(sections: readonly AdminSection[], opts: {
207
217
  const labeled = section.routes.filter((route) => route.label);
208
218
  const destinations: MenuItem[] = labeled.length > 0 ? labeled.map((route) => ({
209
219
  id: `admin:${section.id}:${route.path || 'index'}`, label: route.label!, keywords: [section.title],
210
- route: absolutePath(base, route.path), exact: true, permissionClauses: [section.permissions, ...(route.permissions ? [route.permissions] : [])],
220
+ route: absolutePath(base, route.path), exact: true, requireSuperuser: route.requireSuperuser, permissionClauses: [section.permissions, ...(route.permissions ? [route.permissions] : [])],
211
221
  })) : [{ id: `admin:${section.id}`, label: section.title, keywords: [section.title], route: absolutePath(base), exact: true, permissionClauses: [section.permissions] }];
212
222
  if (section.navigationGroup === null) {
213
223
  items.push(...destinations.map((destination) => ({ ...destination, icon: section.icon })));
@@ -228,7 +238,7 @@ export function adminSectionsMenu(sections: readonly AdminSection[], opts: {
228
238
  items.push({
229
239
  label: section.title, icon: section.icon, permissions: section.permissions,
230
240
  ...(base ? { route: absolutePath(base), exact: true } : {}),
231
- children: labeled.map((route) => ({ label: route.label!, route: absolutePath(base, route.path), exact: true, permissions: route.permissions })),
241
+ children: labeled.map((route) => ({ label: route.label!, route: absolutePath(base, route.path), exact: true, permissions: route.permissions, requireSuperuser: route.requireSuperuser })),
232
242
  });
233
243
  }
234
244
  return { name: opts.name ?? 'admin', scope: 'admin', presentation: opts.presentation ?? 'static', items };
@@ -5,6 +5,7 @@ export { ASSISTANT_PERMISSIONS } from '../assistant/permissions';
5
5
  export const ASSISTANT_ADMIN_SECTION: AdminSection = {
6
6
  id: 'assistant', basePath: 'assistant', title: 'Assistant', icon: 'bi-stars', navigationGroup: 'assistant', permissions: ASSISTANT_PERMISSIONS,
7
7
  routes: [
8
+ { path: 'setup', label: 'Setup', requireSuperuser: true, loadComponent: () => import('../assistant/AssistantSetupPage').then(({ AssistantSetupPage }) => ({ default: AssistantSetupPage })) },
8
9
  { path: 'conversations', label: 'Conversations', loadComponent: () => import('../assistant/pages').then(({ ConversationsPage }) => ({ default: ConversationsPage })), permissions: ASSISTANT_PERMISSIONS },
9
10
  { path: 'skills', label: 'Skills', loadComponent: () => import('../assistant/pages').then(({ SkillsPage }) => ({ default: SkillsPage })), permissions: ASSISTANT_PERMISSIONS },
10
11
  { path: 'memories', label: 'Memory', loadComponent: () => import('../assistant/pages').then(({ MemoriesPage }) => ({ default: MemoriesPage })), permissions: ['sys.assistant'] },
@@ -192,6 +192,7 @@ function UserDetailLoaded({ user, isAdmin, hasAppPerms, onClose, onOpenGroup }:
192
192
  },
193
193
  { divider: true },
194
194
  { label: 'Change password', icon: 'bi-key', permissions: ADMIN, onSelect: () => void actions.changePassword() },
195
+ { label: 'Set temporary password', icon: 'bi-key', permissions: ADMIN, onSelect: () => void actions.setTemporaryPassword() },
195
196
  { label: 'Send password reset', icon: 'bi-envelope', permissions: ADMIN, onSelect: () => void actions.sendPasswordReset() },
196
197
  { label: 'Send magic login link', icon: 'bi-link-45deg', permissions: ADMIN, onSelect: () => void actions.sendMagicLink() },
197
198
  {
@@ -55,6 +55,10 @@ export function SecuritySection({ user, actions, canManage, canPasskeys }: {
55
55
  </SecurityItem>
56
56
  )}
57
57
 
58
+ {canManage && <SecurityItem icon="bi-key" title="Temporary password" desc="Generate a password that the user must replace when signing in">
59
+ <button className="btn btn-compact" onClick={() => void actions.setTemporaryPassword()}>Set temporary password…</button>
60
+ </SecurityItem>}
61
+
58
62
  <Eyebrow>Multi-factor authentication</Eyebrow>
59
63
 
60
64
  {canManage && (
@@ -10,11 +10,12 @@
10
10
  // the public, admin-targetable verification sender (NOT the JWT-scoped
11
11
  // auth/verify/email/send).
12
12
  import { useQueryClient } from '@tanstack/react-query';
13
- import { useEffect, useState } from 'react';
13
+ import { useEffect, useRef, useState } from 'react';
14
14
  import { mojoCall, withFreshAuth, type Field } from '../../../../client/runtime';
15
15
  import {
16
16
  ImageField,
17
17
  PasswordStrengthMeter,
18
+ SecretBox,
18
19
  formModal,
19
20
  modal,
20
21
  toast,
@@ -46,6 +47,7 @@ export interface UserAdminActions {
46
47
  editUser: () => Promise<void>;
47
48
  editAccount: () => Promise<void>;
48
49
  changePassword: () => Promise<void>;
50
+ setTemporaryPassword: () => Promise<void>;
49
51
  resetMfa: () => Promise<void>;
50
52
  toggleMfaRequirement: () => Promise<void>;
51
53
  disableTotp: () => Promise<void>;
@@ -65,6 +67,7 @@ export function useUserAdminActions(
65
67
  opts: { throttleRetry?: number; refetchThrottle?: () => void; onOpenGroup?: (groupId: number) => void } = {},
66
68
  ): UserAdminActions {
67
69
  const qc = useQueryClient();
70
+ const canManage = useAdminCaller();
68
71
  const save = UserModel.useSave();
69
72
  const disable = UserModel.useAction('disable');
70
73
  const reactivate = UserModel.useAction('reactivate');
@@ -342,6 +345,18 @@ export function useUserAdminActions(
342
345
  await modal.open((close) => <ChangePasswordModal user={user} onClose={() => close(null)} />);
343
346
  };
344
347
 
348
+ const setTemporaryPassword = async () => {
349
+ if (!canManage) return;
350
+ let pending = false;
351
+ let issued = false;
352
+ await modal.open((close) => <TemporaryPasswordModal user={user}
353
+ onClose={() => close(null)} onIssued={() => { issued = true; }} onPendingChange={(next) => { pending = next; }} />,
354
+ { canDismiss: () => !pending });
355
+ // Issuing for yourself revokes this session too. Keep the reveal visible
356
+ // until dismissed before a refetch can discover that revocation.
357
+ if (issued) void UserModel.invalidate(qc);
358
+ };
359
+
345
360
  // ── MFA ───────────────────────────────────────────────────────────
346
361
 
347
362
  const resetMfa = async () => {
@@ -524,7 +539,7 @@ export function useUserAdminActions(
524
539
  return {
525
540
  sendMagicLink, sendPasswordReset, sendVerificationEmail, setVerification,
526
541
  editDisplayName, editUsername, editEmail, editPhone, setPhone, clearPhone,
527
- editUser, editAccount, changePassword,
542
+ editUser, editAccount, changePassword, setTemporaryPassword,
528
543
  resetMfa, toggleMfaRequirement, disableTotp,
529
544
  clearRateLimit, revokeAllSessions, resendInvite,
530
545
  disableUser, reactivateUser,
@@ -534,6 +549,64 @@ export function useUserAdminActions(
534
549
 
535
550
  // ── Modal bodies (proper components so hooks are legal) ───────────────
536
551
 
552
+ /** The generated secret lives only in this mounted dialog, outside Query/Mutation cache. */
553
+ function TemporaryPasswordModal({ user, onClose, onIssued, onPendingChange }: {
554
+ user: UserRow; onClose: () => void; onIssued: () => void; onPendingChange: (pending: boolean) => void;
555
+ }) {
556
+ const canManage = useAdminCaller();
557
+ const mounted = useRef(true);
558
+ const pending = useRef(false);
559
+ const [busy, setBusy] = useState(false);
560
+ const [secret, setSecret] = useState<string | null>(null);
561
+ const [error, setError] = useState<string | null>(null);
562
+ useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
563
+ useEffect(() => { if (!canManage) onClose(); }, [canManage, onClose]);
564
+
565
+ const submit = async () => {
566
+ if (!canManage || pending.current || secret) return;
567
+ pending.current = true;
568
+ onPendingChange(true);
569
+ setBusy(true);
570
+ setError(null);
571
+ try {
572
+ const response = await withFreshAuth(() => mojoCall('/api/account/admin/user/password/temporary', {
573
+ method: 'POST', body: { user: user.id },
574
+ }));
575
+ const result = response.data as { user?: number; requires_password_change?: boolean; temporary_password?: string } | undefined;
576
+ if (result?.user !== user.id || result.requires_password_change !== true || typeof result.temporary_password !== 'string' || !result.temporary_password) {
577
+ throw new Error('The server did not return a temporary password.');
578
+ }
579
+ if (mounted.current) {
580
+ setSecret(result.temporary_password);
581
+ onIssued();
582
+ }
583
+ } catch (err) {
584
+ if (mounted.current) setError(err instanceof Error ? err.message : 'Failed to set temporary password');
585
+ } finally {
586
+ pending.current = false;
587
+ onPendingChange(false);
588
+ if (mounted.current) setBusy(false);
589
+ }
590
+ };
591
+
592
+ if (!canManage) return null;
593
+ return <div className="modal-pad">
594
+ <h2 className="modal-title">{secret ? 'Temporary password' : 'Set temporary password'}</h2>
595
+ <p className="modal-message">{secret
596
+ ? <>Save this password now and share it securely with <b>{user.display_name || user.username}</b>. It is shown only once.</>
597
+ : <>Generate a temporary password for <b>{user.display_name || user.username}</b>? This replaces their password and signs them out of all sessions.</>}</p>
598
+ <div className="ga-secret-warning" role="alert">They must change their password before they can finish signing in.</div>
599
+ {secret && <SecretBox secret={secret} ariaLabel="Temporary password" />}
600
+ {error && <div className="field-error" role="alert">{error}</div>}
601
+ <div className="modal-actions">
602
+ <button className="btn" onClick={onClose} disabled={busy}>{secret ? 'Close' : 'Cancel'}</button>
603
+ {!secret && <button className="btn btn-primary" onClick={() => void submit()} disabled={busy}>
604
+ {busy ? 'Generating…' : 'Set temporary password'}
605
+ </button>}
606
+ </div>
607
+ </div>;
608
+ }
609
+
537
610
  /**
538
611
  * Admin direct password set — `model.save({new_password})`; django-mojo lets
539
612
  * the users/manage_users tier set it without current_password. Built by hand
@@ -163,15 +163,26 @@ export function toMs(value: number | string | null | undefined): number | null {
163
163
 
164
164
  /** Day-relative header label: Today / Yesterday / Mon, Jun 3. */
165
165
  export function dayLabel(value: number | string | null | undefined): string {
166
- const ms = toMs(value);
167
- if (ms == null) return 'Unknown date';
168
- const d = new Date(ms);
169
- const today = new Date();
170
- const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
171
- const diffDays = Math.round((startOf(today) - startOf(d)) / 864e5);
166
+ // Calendar parts, not the instant: a date-only value is its own day in
167
+ // every zone (a UTC-midnight instant reads as the day before west of UTC).
168
+ // Both days become UTC-midnight stamps of their calendar fields, so no
169
+ // local midnight (which a zone can skip) and no 2-digit-year mapping is
170
+ // involved, and the label is formatted in UTC from those same fields.
171
+ const p = dateFns.calendarParts(value);
172
+ if (!p) return 'Unknown date';
173
+ const now = new Date();
174
+ const day = utcDay(p.y, p.m, p.d);
175
+ const diffDays = Math.round((utcDay(now.getFullYear(), now.getMonth() + 1, now.getDate()) - day) / 864e5);
172
176
  if (diffDays === 0) return 'Today';
173
177
  if (diffDays === 1) return 'Yesterday';
174
- return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
178
+ return new Date(day).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' });
179
+ }
180
+
181
+ /** Epoch ms of UTC midnight on a calendar day; setUTCFullYear keeps years 0–99. */
182
+ function utcDay(y: number, m: number, d: number): number {
183
+ const t = new Date(0);
184
+ t.setUTCFullYear(y, m - 1, d);
185
+ return t.getTime();
175
186
  }
176
187
 
177
188
  /**
@@ -32,7 +32,7 @@ export * from './realtime-mock';
32
32
  export * from './types';
33
33
  // Dev affordance: per-endpoint call counts from the mock (single-flight proof).
34
34
  export {
35
- armMockReauth, clearMockRequestHistory, getMockCallCounts,
35
+ armMockReauth, clearMockRequestHistory, getMockCallCounts, setMockAssistantSetupScenario,
36
36
  getMockRequestHistory, setMockDnsConfigMalformed, setMockDnsAcmeMode, armMockDnsWriteFault,
37
37
  getMockPhoneConfigCredentialState, getMockEffectivePhoneConfigId,
38
38
  deleteMockPhoneGroupForTest,
@@ -40,3 +40,5 @@ export {
40
40
  getMockPushConfigCredentialState,
41
41
  armMockUploadFault, armMockRecordNoteFault, clearMockUploadObservations, getMockUploadObservations, setMockUploadMode,
42
42
  } from './mock';
43
+
44
+ export type { MockAssistantSetupScenario } from './mock';