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 +12 -0
- package/docs/README.md +1 -1
- package/docs/admin-assistant.md +152 -1
- package/docs/admin-credentials.md +11 -0
- package/docs/admin-identity-users.md +10 -0
- package/docs/grouping-and-fmt.md +1 -1
- package/package.json +1 -1
- package/src/account/PasskeyList.tsx +1 -1
- package/src/account/credential-models.ts +2 -1
- package/src/admin/assistant/AssistantSetupPage.tsx +196 -0
- package/src/admin/assistant/index.ts +2 -0
- package/src/admin/assistant/setup.ts +174 -0
- package/src/admin/core/index.ts +14 -4
- package/src/admin/domains/assistant.ts +1 -0
- package/src/admin/identity/users/UserDetail.tsx +1 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +4 -0
- package/src/admin/identity/users/sections/actions.tsx +75 -2
- package/src/admin/identity/users/sections/shared.tsx +18 -7
- package/src/client/index.ts +3 -1
- package/src/client/mock.ts +194 -8
- package/src/styles/components/admin-assistant.css +22 -0
- package/src/ui/date/fns.ts +32 -0
- package/src/ui/format.ts +26 -1
- package/src/ui/grouping.ts +31 -16
- package/src/ui/menu-registry.ts +3 -0
|
@@ -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
|
+
}
|
package/src/admin/core/index.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
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
|
|
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
|
/**
|
package/src/client/index.ts
CHANGED
|
@@ -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';
|