@jimhoyd/urlcode-admin 0.1.0-alpha.1
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/IMPLEMENTATION-STATUS.md +28 -0
- package/LICENSE +202 -0
- package/README.md +292 -0
- package/SECURITY.md +19 -0
- package/THREAT-MODEL.md +34 -0
- package/UX-REVIEW.md +61 -0
- package/dist/admin-account.d.ts +15 -0
- package/dist/admin-account.js +82 -0
- package/dist/admin-audit-export.d.ts +7 -0
- package/dist/admin-audit-export.js +38 -0
- package/dist/admin-copy.d.ts +64 -0
- package/dist/admin-copy.js +71 -0
- package/dist/admin-dashboard.d.ts +2 -0
- package/dist/admin-dashboard.js +14 -0
- package/dist/admin-detail.d.ts +14 -0
- package/dist/admin-detail.js +27 -0
- package/dist/admin-health.d.ts +24 -0
- package/dist/admin-health.js +33 -0
- package/dist/admin-presentation.d.ts +3 -0
- package/dist/admin-presentation.js +20 -0
- package/dist/admin-recovery.d.ts +24 -0
- package/dist/admin-recovery.js +78 -0
- package/dist/admin-reporting.d.ts +14 -0
- package/dist/admin-reporting.js +161 -0
- package/dist/admin-runtime.d.ts +16 -0
- package/dist/admin-runtime.js +22 -0
- package/dist/admin-screens.d.ts +32 -0
- package/dist/admin-screens.js +61 -0
- package/dist/admin-templates.d.ts +13 -0
- package/dist/admin-templates.js +76 -0
- package/dist/admin-ui.d.ts +65 -0
- package/dist/admin-ui.js +71 -0
- package/dist/admin-user-export.d.ts +3 -0
- package/dist/admin-user-export.js +57 -0
- package/dist/admin-users.d.ts +13 -0
- package/dist/admin-users.js +19 -0
- package/dist/admin.d.ts +37 -0
- package/dist/admin.js +304 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +16 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +6 -0
- package/dist/scaffold.d.ts +38 -0
- package/dist/scaffold.js +46 -0
- package/dist/support-banner.d.ts +11 -0
- package/dist/support-banner.js +43 -0
- package/package.json +56 -0
package/UX-REVIEW.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Administration UX review
|
|
2
|
+
|
|
3
|
+
This review covers the HTML console, its source routes and focused regression
|
|
4
|
+
checks. It is a usability and accessibility pass, not WCAG certification or an
|
|
5
|
+
independent security assessment. The console uses shared URLCode UI primitives;
|
|
6
|
+
account terminology, permissions, workflows and copy remain in this repository.
|
|
7
|
+
|
|
8
|
+
| Screen or concern | Finding | Change |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| Shared shell | The skip link stopped before the sidebar; the visible title was an h2 below a hidden h1. | One visible page h1 and a focusable content target after navigation. Native mobile navigation stays collapsed until opened. Security response headers and theme scripts are preserved. |
|
|
11
|
+
| Overview | Exact-value tables could overflow and an empty event list gave no explanation. | Keyboard-focusable table regions and explicit empty activity feedback. Metric links retain permission filtering. |
|
|
12
|
+
| Users | Lock/revoke controls had the same visual weight as search; identifiers and dates were dense. | Destructive button treatment, concise UTC dates with exact machine-readable timestamps, masked identifiers, expandable actions, and retained filter selections. Default search fields keep advanced filters closed. |
|
|
13
|
+
| Account details | Notes, session revocation and identifier reveal competed with account facts; recovery links appeared when no delivery integration existed. | Clear fact grid, section navigation, expandable sensitive actions, note guidance, empty states, and recovery links gated by actual availability. Consent records and user content remain escaped. |
|
|
14
|
+
| Roles | Ungrouped role/grant lists and assignment fields obscured the read-only policy boundary. | Permission table plus a separate assignment card, descriptive empty grants and explicit read-only configuration explanation. |
|
|
15
|
+
| Sessions | Long prose lists mixed identity, dates and immediate revoke forms. | Filter card, table with masked accounts and readable timestamps, empty results, expandable destructive actions and a separate revoke-all section. |
|
|
16
|
+
| Audit | Submitted filters were not retained, labels exposed raw parameter names and export competed with filtering. | Persistent input values, human-readable labels, explicit UTC format, scrollable event table, empty state, and separate export disclosure. |
|
|
17
|
+
| Health | Raw enum alerts and missing empty states made a configured-but-quiet service look unfinished. | Grouped facts, status badges, plain-language alert copy and explicit absence of reported alerts. Unknown sender/provider observations remain unknown. |
|
|
18
|
+
| Support cases | All mutation forms appeared at once, and action input was free text. | Case cards with expandable notes/closure/approval, a constrained action select and empty queue feedback. Two-person authority checks are unchanged. |
|
|
19
|
+
| Registration | An empty queue rendered no content. | Explicit empty queue feedback and a separate invitation card when delivery is configured. |
|
|
20
|
+
| Account operations | Every destructive operation showed a full form immediately. | Grouped methods and expandable action cards; password invalidation, deletion and method removal use destructive styling. Existing typed confirmations, reasons and notification gates remain required. |
|
|
21
|
+
| Manual recovery | Empty lists and long identifiers weakened hierarchy. | Explicit empty state, bounded evidence cards, readable identifiers, grouped creation and destructive approval styling. Evidence is still a human procedure, never automatic proof. |
|
|
22
|
+
| Completed mutations | Confirmation pages left no clear next step. | Status feedback and a return link to overview or users. |
|
|
23
|
+
| Copy ownership | New console text could not be translated without extending auth's nearly full catalogue. | Admin owns a separate bounded catalogue and a presentation factory that composes it with existing auth copy, without raising shared catalogue limits. |
|
|
24
|
+
|
|
25
|
+
## Verification
|
|
26
|
+
|
|
27
|
+
`npm run verify` passes 36 tests, including existing authorization, CSRF, freshness,
|
|
28
|
+
notification, recovery, export and HTTP integration regressions. Added checks cover
|
|
29
|
+
all principal console pages sharing one h1 and the skip target, preserved audit
|
|
30
|
+
filters, meaningful empty states, unavailable recovery-link omission, destructive
|
|
31
|
+
session forms retaining CSRF/reason fields, and admin-owned translation overrides.
|
|
32
|
+
|
|
33
|
+
The final reviewed-package browser pass must additionally check desktop and narrow
|
|
34
|
+
layouts, keyboard focus, mobile navigation, light/dark/system themes, long labels,
|
|
35
|
+
empty/populated tables and provider-dependent screens. Typeface, palette, control
|
|
36
|
+
sizing and theme behavior are supplied by URLCode UI and need verification at the
|
|
37
|
+
exact integrated package revision. No live email/OIDC provider verification,
|
|
38
|
+
comprehensive screen-reader audit or production data/soak validation is claimed.
|
|
39
|
+
|
|
40
|
+
Error responses still use the shared auth failure renderer to preserve its
|
|
41
|
+
redaction and security behavior. JSON schemas and authority decisions are
|
|
42
|
+
unchanged. Historic method/activity timestamps remain unknown rather than invented.
|
|
43
|
+
|
|
44
|
+
## Compact console follow-up
|
|
45
|
+
|
|
46
|
+
The default templates now omit the overview's implementation instructions, repeated
|
|
47
|
+
filter headings and obvious introductory paragraphs on sessions, audit, roles and
|
|
48
|
+
health. The console shell has one page title without repeating the product and
|
|
49
|
+
section above it. Users retain a visible masked-account caption and result count;
|
|
50
|
+
the duplicate table caption remains available to assistive technology. Search,
|
|
51
|
+
account setup and CSV export labels are shorter and owned by the admin catalogue.
|
|
52
|
+
|
|
53
|
+
Template view fields remain available to project overrides. Empty states, errors,
|
|
54
|
+
export limits, role-definition constraints, reason fields, destructive action
|
|
55
|
+
consequences and fresh-authentication requirements are retained. Both primitive
|
|
56
|
+
and kit rendering paths use the same compact screen templates.
|
|
57
|
+
|
|
58
|
+
This follow-up passes all 61 admin tests, including both render paths, localization,
|
|
59
|
+
strict CSP, immutable kit assets and all existing permission/mutation checks. The
|
|
60
|
+
clean-project harness can now repeat installed-package acceptance with `--kit`;
|
|
61
|
+
that run remains distinct from visual browser and accessibility review.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ScreenOptions } from './admin-ui.ts';
|
|
2
|
+
import type { ExtensionRequest, ExtensionInstance } from '@jimhoyd/urlcode/extensions';
|
|
3
|
+
import { AuthHttp } from '@jimhoyd/urlcode-auth';
|
|
4
|
+
import type { AuthPrincipal, AdminAccountService, AdminAccountDelivery } from '@jimhoyd/urlcode-auth';
|
|
5
|
+
interface AdminAccountOptions {
|
|
6
|
+
service: AdminAccountService;
|
|
7
|
+
sendAccountAdministration?: (message: AdminAccountDelivery & {
|
|
8
|
+
signal: AbortSignal;
|
|
9
|
+
}) => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare function createAdminAccount(options: AdminAccountOptions, http: AuthHttp, mount: string): {
|
|
12
|
+
enabled: () => boolean;
|
|
13
|
+
handle(request: ExtensionRequest, principal: AuthPrincipal, actorToken: string, render: ScreenOptions): Promise<Awaited<ReturnType<ExtensionInstance["handle"]>> | undefined>;
|
|
14
|
+
};
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { escapeHtml, hiddenField as hidden, withDeadline } from '@jimhoyd/urlcode-ui';
|
|
2
|
+
import { markup, screenResponse } from "./admin-ui.js";
|
|
3
|
+
import { AuthHttp, AuthHttpError, csrfField, formField, hasPermission, jsonResponse, readFields, wantsJson } from '@jimhoyd/urlcode-auth';
|
|
4
|
+
const actions = ['verify-email', 'force-password-reset', 'schedule-deletion', 'cancel-deletion', 'remove-passkey', 'remove-external', 'request-email-change', 'assign-roles', 'resend-verification'];
|
|
5
|
+
export function createAdminAccount(options, http, mount) {
|
|
6
|
+
let callbacks = 0, batches = 0;
|
|
7
|
+
return { enabled: () => !!options.sendAccountAdministration, async handle(request, principal, actorToken, render) {
|
|
8
|
+
if (request.path.slice(mount.length) !== '/account-operations')
|
|
9
|
+
return;
|
|
10
|
+
if (!options.sendAccountAdministration)
|
|
11
|
+
throw new AuthHttpError(404, 'Not found');
|
|
12
|
+
if (principal.impersonatorId || !hasPermission(principal, 'auth.users.read'))
|
|
13
|
+
throw new AuthHttpError(403, 'Permission required');
|
|
14
|
+
const { presentation } = render, tr = (key, values) => presentation.text('adminOps.' + key.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()), values), html = (key, values) => escapeHtml(tr(key, values)), csrf = http.token(actorToken);
|
|
15
|
+
const form = (action, ids, fields = '') => { const destructive = ['force-password-reset', 'schedule-deletion', 'remove-passkey', 'remove-external'].includes(action); return `<details class="ui-card${destructive ? ' ui-danger-zone' : ''}"><summary>${html('action.' + action)}</summary><form class="ui-form-grid" method="post" action="${escapeHtml(mount + '/account-operations')}">${csrfField(csrf)}${hidden('action', action)}${hidden('accountIds', ids)}${fields}${formField('reason', tr('reason'))}${formField('confirmation', tr('confirm', { value: action.toUpperCase() + ' ' + ids.split(',').length }))}<div class="ui-actions"><button${destructive ? ' class="ui-button-destructive"' : ''} type="submit">${html('action.' + action)}</button></div></form></details>`; };
|
|
16
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
17
|
+
const ids = request.query.getAll('accountId');
|
|
18
|
+
if (ids.length > 1)
|
|
19
|
+
throw new AuthHttpError(400, 'One account ID required');
|
|
20
|
+
const accountId = ids[0];
|
|
21
|
+
const screen = (view) => screenResponse(tr('title'), { name: 'admin/account-operations', view: view }, render);
|
|
22
|
+
if (!accountId)
|
|
23
|
+
return screen({ bulk: { info: tr('bulkInfo'), form: markup(`<form class="ui-form-grid" method="post" action="${escapeHtml(mount + '/account-operations')}">${csrfField(csrf)}${formField('accountIds', tr('ids'))}<label>${html('actionLabel')}<select name="action"><option value="assign-roles">${html('action.assign-roles')}</option><option value="resend-verification">${html('action.resend-verification')}</option></select></label>${formField('roles', tr('roles'), 'text', 'off', false)}${formField('reason', tr('reason'))}${formField('confirmation', tr('bulkConfirm'))}<div class="ui-actions"><button type="submit">${html('apply')}</button></div></form>`) }, account: null });
|
|
24
|
+
const methods = await options.service.inspectAccountAuthentication({ actorToken, accountId, reason: 'Viewed account authentication methods' });
|
|
25
|
+
if (wantsJson(request))
|
|
26
|
+
return jsonResponse(200, { ...methods, csrf });
|
|
27
|
+
const manage = hasPermission(principal, 'auth.users.manage');
|
|
28
|
+
const dates = (method) => `<span class="ui-muted">${escapeHtml(presentation.textSource('Added'))}: ${escapeHtml(method.added ? new Date(method.added).toISOString() : presentation.textSource('Not recorded'))}; ${escapeHtml(presentation.textSource('Last used'))}: ${escapeHtml(method.lastUsed ? new Date(method.lastUsed).toISOString() : presentation.textSource('Not recorded'))}</span>`;
|
|
29
|
+
const methodItems = methods.passkeys.map(method => `<li class="ui-card"><strong>${html('passkey')}</strong> <code>${escapeHtml(method.id)}</code> ${dates(method)} ${method.secondFactor ? html('secondFactor') : manage ? form('remove-passkey', accountId, hidden('credentialId', method.id)) : ''}</li>`).join('') + methods.external.map(method => `<li class="ui-card"><strong>${escapeHtml(method.provider)}</strong> ${dates(method)}${manage ? form('remove-external', accountId, hidden('externalId', method.id)) : ''}</li>`).join('');
|
|
30
|
+
return screen({ bulk: null, account: { idLabel: presentation.textSource('Account ID'), accountId, info: tr('info'), facts: [{ term: tr('password'), value: tr(methods.password ? 'yes' : 'no') }, { term: tr('totp'), value: tr(methods.totp ? 'yes' : 'no') }], methods: markup(methodItems), empty: !methods.passkeys.length && !methods.external.length ? presentation.textSource('No passkeys or external sign-in methods recorded.') : null, caseHref: mount + '/cases', caseLabel: tr('factorCase'), actions: markup(manage ? ['verify-email', 'resend-verification', 'force-password-reset', 'schedule-deletion', 'cancel-deletion'].map(action => form(action, accountId)).join('') + form('request-email-change', accountId, formField('email', tr('email'), 'email')) + form('assign-roles', accountId, formField('roles', tr('roles'))) : '') } });
|
|
31
|
+
}
|
|
32
|
+
if (request.method !== 'POST')
|
|
33
|
+
throw new AuthHttpError(405, 'GET, HEAD or POST required');
|
|
34
|
+
if (!hasPermission(principal, 'auth.users.manage'))
|
|
35
|
+
throw new AuthHttpError(403, 'Permission required');
|
|
36
|
+
const fields = readFields(request, ['action', 'accountIds', 'roles', 'email', 'credentialId', 'externalId', 'reason', 'confirmation']);
|
|
37
|
+
http.verify(request, fields);
|
|
38
|
+
const action = fields.action;
|
|
39
|
+
if (!actions.includes(action))
|
|
40
|
+
throw new AuthHttpError(400, 'Invalid account action');
|
|
41
|
+
const accountIds = (fields.accountIds || '').split(',').map(id => id.trim()).filter(Boolean);
|
|
42
|
+
if (fields.confirmation !== action.toUpperCase() + ' ' + accountIds.length)
|
|
43
|
+
throw new AuthHttpError(400, 'Typed confirmation must match action and count');
|
|
44
|
+
const base = { actorToken, accountIds, reason: fields.reason || '' };
|
|
45
|
+
let input;
|
|
46
|
+
if (action === 'assign-roles')
|
|
47
|
+
input = { ...base, action, roles: (fields.roles || '').split(',').map(role => role.trim()).filter(Boolean) };
|
|
48
|
+
else if (action === 'request-email-change')
|
|
49
|
+
input = { ...base, action, email: fields.email || '' };
|
|
50
|
+
else if (action === 'remove-passkey')
|
|
51
|
+
input = { ...base, action, credentialId: fields.credentialId || '' };
|
|
52
|
+
else if (action === 'remove-external')
|
|
53
|
+
input = { ...base, action, externalId: fields.externalId || '' };
|
|
54
|
+
else
|
|
55
|
+
input = { ...base, action };
|
|
56
|
+
if (batches >= 4 || callbacks >= 4)
|
|
57
|
+
throw new AuthHttpError(503, 'Account administration delivery busy');
|
|
58
|
+
batches++;
|
|
59
|
+
let operationId;
|
|
60
|
+
const deadline = Date.now() + 30000;
|
|
61
|
+
try {
|
|
62
|
+
const staged = await options.service.stageAccountAdministration(input);
|
|
63
|
+
operationId = staged.operationId;
|
|
64
|
+
for (const message of staged.deliveries) {
|
|
65
|
+
if (callbacks >= 4 || Date.now() >= deadline)
|
|
66
|
+
throw new AuthHttpError(503, 'Account administration delivery busy');
|
|
67
|
+
callbacks++;
|
|
68
|
+
await withDeadline(signal => { const pending = Promise.resolve().then(() => options.sendAccountAdministration({ ...message, signal })); void pending.finally(() => { callbacks--; }).catch(() => { }); return pending; }, Math.min(5000, deadline - Date.now()), 'Account notice delivery timed out');
|
|
69
|
+
}
|
|
70
|
+
const result = await options.service.completeAccountAdministration({ actorToken, operationId });
|
|
71
|
+
return wantsJson(request) ? jsonResponse(200, result) : screenResponse(tr('completed'), { name: 'admin/status', view: { alert: false, message: tr('affected', { count: result.affected }), href: mount + '/users', label: presentation.textSource('Back to users') } }, render);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (operationId)
|
|
75
|
+
await options.service.cancelAccountAdministration({ actorToken, operationId }).catch(() => { });
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
batches--;
|
|
80
|
+
}
|
|
81
|
+
} };
|
|
82
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AuthService } from '@jimhoyd/urlcode-auth';
|
|
2
|
+
/** Complete bounded range export: never silently truncate a result or return partial unauthorized data. */
|
|
3
|
+
export declare function exportAuditRange(service: AuthService, actorToken: string, query: URLSearchParams): Promise<{
|
|
4
|
+
from: number;
|
|
5
|
+
to: number;
|
|
6
|
+
events: Awaited<ReturnType<AuthService['listAudit']>>['events'];
|
|
7
|
+
}>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AuthHttpError, hasPermission } from '@jimhoyd/urlcode-auth';
|
|
2
|
+
import { auditFilters } from "./admin-reporting.js";
|
|
3
|
+
/** Complete bounded range export: never silently truncate a result or return partial unauthorized data. */
|
|
4
|
+
export async function exportAuditRange(service, actorToken, query) {
|
|
5
|
+
if (query.has('after'))
|
|
6
|
+
throw new AuthHttpError(400, 'Range exports start at the beginning of the selected range');
|
|
7
|
+
const filters = auditFilters(query);
|
|
8
|
+
if (filters.from === undefined || filters.to === undefined)
|
|
9
|
+
throw new AuthHttpError(400, 'Choose both UTC range endpoints');
|
|
10
|
+
const from = filters.from, to = Math.min(filters.to, Date.now());
|
|
11
|
+
if (from > to)
|
|
12
|
+
throw new AuthHttpError(400, 'Choose a range that has started');
|
|
13
|
+
const events = [];
|
|
14
|
+
let after, bytes = 0;
|
|
15
|
+
const started = Date.now(), seen = new Set();
|
|
16
|
+
do {
|
|
17
|
+
const principal = await service.authenticate(actorToken);
|
|
18
|
+
if (!principal || principal.impersonatorId || !hasPermission(principal, 'auth.audit.read') || !hasPermission(principal, 'auth.audit.export'))
|
|
19
|
+
throw new AuthHttpError(403, 'Audit export permission required');
|
|
20
|
+
if (Date.now() - started > 5000)
|
|
21
|
+
throw new AuthHttpError(413, 'Choose a smaller audit range');
|
|
22
|
+
const page = await service.listAudit({ ...filters, from, to, limit: 100, ...(after ? { after } : {}) });
|
|
23
|
+
bytes += Buffer.byteLength(JSON.stringify(page.events));
|
|
24
|
+
if (events.length + page.events.length > 5000 || bytes > 4 * 1024 * 1024)
|
|
25
|
+
throw new AuthHttpError(413, 'Choose a smaller audit range');
|
|
26
|
+
events.push(...page.events);
|
|
27
|
+
after = page.next;
|
|
28
|
+
if (after) {
|
|
29
|
+
if (seen.has(after))
|
|
30
|
+
throw new AuthHttpError(503, 'Audit pagination unavailable');
|
|
31
|
+
seen.add(after);
|
|
32
|
+
}
|
|
33
|
+
} while (after);
|
|
34
|
+
const principal = await service.authenticate(actorToken);
|
|
35
|
+
if (!principal || principal.impersonatorId || !hasPermission(principal, 'auth.audit.read') || !hasPermission(principal, 'auth.audit.export'))
|
|
36
|
+
throw new AuthHttpError(403, 'Audit export permission required');
|
|
37
|
+
return { from, to, events };
|
|
38
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PresentationOptions, Presentation } from '@jimhoyd/urlcode-ui';
|
|
2
|
+
/** Console copy belongs to admin. Shared UI contains no account or operator workflows. */
|
|
3
|
+
export declare const adminCatalogue: Readonly<{
|
|
4
|
+
'adminUi.activityPeriod': "Last 30 days";
|
|
5
|
+
'adminUi.activityDetails': "View activity totals";
|
|
6
|
+
'adminUi.searchAccounts': "Search accounts";
|
|
7
|
+
'adminUi.searchPlaceholder': "Email, name or account ID";
|
|
8
|
+
'adminUi.createAccount': "Create account";
|
|
9
|
+
'adminUi.sendSetup': "Send setup link";
|
|
10
|
+
'adminUi.exportPage': "Export page (CSV)";
|
|
11
|
+
'adminUi.exportAll': "Export all (CSV)";
|
|
12
|
+
'adminUi.chartDescription': "Thirty UTC days of sign-ups, sign-ins and failed sign-ins. Failed sign-ins use a dashed line. Exact values follow in the daily table.";
|
|
13
|
+
'adminUi.noSessions': "No active sessions match these filters.";
|
|
14
|
+
'adminUi.noAudit': "No audit events match these filters.";
|
|
15
|
+
'adminUi.noCases': "No support cases to review.";
|
|
16
|
+
'adminUi.noRegistrations': "No registration requests are waiting for approval.";
|
|
17
|
+
'adminUi.noNotes': "No administrator notes yet.";
|
|
18
|
+
'adminUi.noActivity': "No account activity recorded.";
|
|
19
|
+
'adminUi.noAlerts': "No alerts reported.";
|
|
20
|
+
'adminUi.noMethods': "No passkeys or external sign-in methods recorded.";
|
|
21
|
+
'adminUi.noRecovery': "No manual recovery cases to review.";
|
|
22
|
+
'adminUi.noGrants': "No additional permissions";
|
|
23
|
+
'adminUi.noTerms': "No consent record is stored for this account.";
|
|
24
|
+
'adminUi.sessionIntro': "Review active sessions. Revoking a session signs that device out.";
|
|
25
|
+
'adminUi.auditIntro': "Review recorded security events. Use filters to narrow the time range or account.";
|
|
26
|
+
'adminUi.rolesIntro': "Review the permissions each role grants. Assign reviewed roles to an account below.";
|
|
27
|
+
'adminUi.methodIntro': "Review sign-in methods before changing account access.";
|
|
28
|
+
'adminUi.auditExport': "Export audit events";
|
|
29
|
+
'adminUi.sessionFilters': "Filter sessions";
|
|
30
|
+
'adminUi.auditFilters': "Filter audit events";
|
|
31
|
+
'adminUi.expires': "Expires";
|
|
32
|
+
'adminUi.device': "Device";
|
|
33
|
+
'adminUi.permissions': "Permissions";
|
|
34
|
+
'adminUi.accountActions': "Account actions";
|
|
35
|
+
'adminUi.revokeExplanation': "The user can sign in again unless their account is locked.";
|
|
36
|
+
'adminUi.resetFilters': "Reset filters";
|
|
37
|
+
'adminUi.advancedFilters': "Advanced filters";
|
|
38
|
+
'adminUi.exportAccounts': "Export accounts";
|
|
39
|
+
'adminUi.noAccounts': "No matching accounts. Try changing your filters.";
|
|
40
|
+
'adminUi.returnOverview': "Return to overview";
|
|
41
|
+
'adminUi.returnUsers': "Back to users";
|
|
42
|
+
'adminUi.reviewCase': "Review case";
|
|
43
|
+
'adminUi.createCase': "Create a support case";
|
|
44
|
+
'adminUi.noteHelp': "Notes are recorded in the audit trail. Do not include credentials or recovery secrets.";
|
|
45
|
+
'adminUi.healthIntro': "Reported observations from this host and its configured integrations.";
|
|
46
|
+
'adminUi.confirmFresh': "Sensitive actions require a recent sign-in and a reason.";
|
|
47
|
+
'adminUi.actor': "Actor account ID";
|
|
48
|
+
'adminUi.subject': "Subject account ID";
|
|
49
|
+
'adminUi.action': "Event action";
|
|
50
|
+
'adminUi.from': "From UTC (YYYY-MM-DDTHH:mmZ)";
|
|
51
|
+
'adminUi.to': "To UTC (YYYY-MM-DDTHH:mmZ)";
|
|
52
|
+
'adminUi.noticeSender': "Notification sender reported a failure.";
|
|
53
|
+
'adminUi.noticeProvider': "A provider credential is approaching expiry.";
|
|
54
|
+
'adminUi.noticePresentation': "The configured presentation needs an update.";
|
|
55
|
+
'adminUi.noticeTranslation': "Some translations are incomplete.";
|
|
56
|
+
}>;
|
|
57
|
+
/**
|
|
58
|
+
* Existing auth presentations still work; this factory also makes admin-owned copy translatable.
|
|
59
|
+
* `base` composes an existing presentation (the kit's, which carries the auth catalogue and the
|
|
60
|
+
* project's copy) with the admin catalogue instead of building the auth presentation here.
|
|
61
|
+
*/
|
|
62
|
+
export declare function createAdminPresentation(options?: Omit<PresentationOptions, 'defaults'> & {
|
|
63
|
+
base?: Presentation | undefined;
|
|
64
|
+
}): Presentation;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createPresentation } from '@jimhoyd/urlcode-ui';
|
|
2
|
+
import { createPresentation as createAuthPresentation } from '@jimhoyd/urlcode-auth';
|
|
3
|
+
/** Console copy belongs to admin. Shared UI contains no account or operator workflows. */
|
|
4
|
+
export const adminCatalogue = Object.freeze({
|
|
5
|
+
'adminUi.activityPeriod': 'Last 30 days',
|
|
6
|
+
'adminUi.activityDetails': 'View activity totals',
|
|
7
|
+
'adminUi.searchAccounts': 'Search accounts',
|
|
8
|
+
'adminUi.searchPlaceholder': 'Email, name or account ID',
|
|
9
|
+
'adminUi.createAccount': 'Create account',
|
|
10
|
+
'adminUi.sendSetup': 'Send setup link',
|
|
11
|
+
'adminUi.exportPage': 'Export page (CSV)',
|
|
12
|
+
'adminUi.exportAll': 'Export all (CSV)',
|
|
13
|
+
'adminUi.chartDescription': 'Thirty UTC days of sign-ups, sign-ins and failed sign-ins. Failed sign-ins use a dashed line. Exact values follow in the daily table.',
|
|
14
|
+
'adminUi.noSessions': 'No active sessions match these filters.',
|
|
15
|
+
'adminUi.noAudit': 'No audit events match these filters.',
|
|
16
|
+
'adminUi.noCases': 'No support cases to review.',
|
|
17
|
+
'adminUi.noRegistrations': 'No registration requests are waiting for approval.',
|
|
18
|
+
'adminUi.noNotes': 'No administrator notes yet.',
|
|
19
|
+
'adminUi.noActivity': 'No account activity recorded.',
|
|
20
|
+
'adminUi.noAlerts': 'No alerts reported.',
|
|
21
|
+
'adminUi.noMethods': 'No passkeys or external sign-in methods recorded.',
|
|
22
|
+
'adminUi.noRecovery': 'No manual recovery cases to review.',
|
|
23
|
+
'adminUi.noGrants': 'No additional permissions',
|
|
24
|
+
'adminUi.noTerms': 'No consent record is stored for this account.',
|
|
25
|
+
'adminUi.sessionIntro': 'Review active sessions. Revoking a session signs that device out.',
|
|
26
|
+
'adminUi.auditIntro': 'Review recorded security events. Use filters to narrow the time range or account.',
|
|
27
|
+
'adminUi.rolesIntro': 'Review the permissions each role grants. Assign reviewed roles to an account below.',
|
|
28
|
+
'adminUi.methodIntro': 'Review sign-in methods before changing account access.',
|
|
29
|
+
'adminUi.auditExport': 'Export audit events',
|
|
30
|
+
'adminUi.sessionFilters': 'Filter sessions',
|
|
31
|
+
'adminUi.auditFilters': 'Filter audit events',
|
|
32
|
+
'adminUi.expires': 'Expires',
|
|
33
|
+
'adminUi.device': 'Device',
|
|
34
|
+
'adminUi.permissions': 'Permissions',
|
|
35
|
+
'adminUi.accountActions': 'Account actions',
|
|
36
|
+
'adminUi.revokeExplanation': 'The user can sign in again unless their account is locked.',
|
|
37
|
+
'adminUi.resetFilters': 'Reset filters',
|
|
38
|
+
'adminUi.advancedFilters': 'Advanced filters',
|
|
39
|
+
'adminUi.exportAccounts': 'Export accounts',
|
|
40
|
+
'adminUi.noAccounts': 'No matching accounts. Try changing your filters.',
|
|
41
|
+
'adminUi.returnOverview': 'Return to overview',
|
|
42
|
+
'adminUi.returnUsers': 'Back to users',
|
|
43
|
+
'adminUi.reviewCase': 'Review case',
|
|
44
|
+
'adminUi.createCase': 'Create a support case',
|
|
45
|
+
'adminUi.noteHelp': 'Notes are recorded in the audit trail. Do not include credentials or recovery secrets.',
|
|
46
|
+
'adminUi.healthIntro': 'Reported observations from this host and its configured integrations.',
|
|
47
|
+
'adminUi.confirmFresh': 'Sensitive actions require a recent sign-in and a reason.',
|
|
48
|
+
'adminUi.actor': 'Actor account ID',
|
|
49
|
+
'adminUi.subject': 'Subject account ID',
|
|
50
|
+
'adminUi.action': 'Event action',
|
|
51
|
+
'adminUi.from': 'From UTC (YYYY-MM-DDTHH:mmZ)',
|
|
52
|
+
'adminUi.to': 'To UTC (YYYY-MM-DDTHH:mmZ)',
|
|
53
|
+
'adminUi.noticeSender': 'Notification sender reported a failure.',
|
|
54
|
+
'adminUi.noticeProvider': 'A provider credential is approaching expiry.',
|
|
55
|
+
'adminUi.noticePresentation': 'The configured presentation needs an update.',
|
|
56
|
+
'adminUi.noticeTranslation': 'Some translations are incomplete.',
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* Existing auth presentations still work; this factory also makes admin-owned copy translatable.
|
|
60
|
+
* `base` composes an existing presentation (the kit's, which carries the auth catalogue and the
|
|
61
|
+
* project's copy) with the admin catalogue instead of building the auth presentation here.
|
|
62
|
+
*/
|
|
63
|
+
export function createAdminPresentation(options = {}) {
|
|
64
|
+
const entries = Object.entries(options.catalogues ?? {});
|
|
65
|
+
const isAdmin = (key) => Object.hasOwn(adminCatalogue, key);
|
|
66
|
+
const { base: given, ...rest } = options;
|
|
67
|
+
const base = given ?? createAuthPresentation({ ...rest, catalogues: Object.fromEntries(entries.map(([locale, catalogue]) => [locale, Object.fromEntries(Object.entries(catalogue).filter(([key]) => !isAdmin(key)))])) });
|
|
68
|
+
const own = createPresentation({ ...rest, defaults: adminCatalogue, catalogues: Object.fromEntries(entries.map(([locale, catalogue]) => [locale, Object.fromEntries(Object.entries(catalogue).filter(([key]) => isAdmin(key)))])) });
|
|
69
|
+
const sources = new Map(Object.entries(adminCatalogue).map(([key, value]) => [value, key]));
|
|
70
|
+
return Object.freeze({ ...base, english: Object.freeze({ ...base.english, ...adminCatalogue }), coverage(locale) { const auth = base.coverage(locale), admin = own.coverage(locale); return { missing: [...auth.missing, ...admin.missing.filter(isAdmin)], mismatched: [...auth.mismatched, ...admin.mismatched.filter(isAdmin)] }; }, resolve(preferences) { const context = base.resolve(preferences), copy = own.resolve(preferences); return Object.freeze({ ...context, has(key) { return isAdmin(key) ? copy.has(key) : context.has(key); }, textSource(source) { const key = sources.get(source); return key ? copy.text(key) : context.textSource(source); }, text(key, values) { return isAdmin(key) ? copy.text(key, values) : context.text(key, values); } }); } });
|
|
71
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { escapeHtml } from '@jimhoyd/urlcode-ui';
|
|
2
|
+
import { hasPermission } from '@jimhoyd/urlcode-auth';
|
|
3
|
+
export function dashboardSummary(stats, mount, principal, presentation) {
|
|
4
|
+
const text = (value) => escapeHtml(presentation.textSource(value));
|
|
5
|
+
const number = (value) => escapeHtml(presentation.text('number.value', { value }));
|
|
6
|
+
const items = [['Accounts', stats.users, '/users', 'auth.users.read'], ['Active accounts', stats.users - stats.locked - stats.pendingDeletion, '/users?status=active', 'auth.users.read'], ['Locked accounts', stats.locked, '/users?status=locked', 'auth.users.read'], ['Pending deletion', stats.pendingDeletion, '/users?status=pending-delete', 'auth.users.read'], ['Sessions', stats.sessions, '/sessions', 'auth.sessions.manage'], ['Waiting for approval', stats.waitlist, '/registrations', 'auth.users.manage']];
|
|
7
|
+
const totals = `<div class="ui-metrics" role="group" aria-label="${escapeHtml(presentation.text('message.adminTotals', { users: stats.users, locked: stats.locked, pending: stats.pendingDeletion, sessions: stats.sessions, waitlist: stats.waitlist }))}">${items.filter(([, , , permission]) => hasPermission(principal, permission)).map(([label, count, path]) => `<a class="ui-card ui-metric" href="${escapeHtml(mount + path)}"><span class="ui-muted">${text(label)}</span><strong>${number(count)}</strong><span aria-hidden="true">↗</span></a>`).join('')}</div>`;
|
|
8
|
+
const days = stats.daily.slice(-30), peak = Math.max(1, ...days.flatMap(day => [day.signUps, day.signIns, day.failedSignIns]));
|
|
9
|
+
const colors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)'];
|
|
10
|
+
const lines = ['signUps', 'signIns', 'failedSignIns'].map((key, index) => `<polyline fill="none" stroke="${colors[index]}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${index === 2 ? ' stroke-dasharray="5 4"' : ''} points="${days.map((day, i) => `${64 + i * 526 / Math.max(1, days.length - 1)},${160 - Math.round(Math.max(0, day[key]) / peak * 130)}`).join(' ')}"/>`).join('');
|
|
11
|
+
const grid = [30, 95, 160].map(y => `<line x1="64" y1="${y}" x2="590" y2="${y}" stroke="currentColor" opacity="0.1"/>`).join('');
|
|
12
|
+
const labels = `<text x="54" y="34" text-anchor="end" font-size="14" fill="currentColor">${number(peak)}</text><text x="54" y="164" text-anchor="end" font-size="14" fill="currentColor">${number(0)}</text>${days.length ? `<text x="64" y="184" font-size="14" fill="currentColor">${escapeHtml(days[0].day)} UTC</text><text x="590" y="184" text-anchor="end" font-size="14" fill="currentColor">${escapeHtml(days.at(-1).day)} UTC</text>` : ''}`;
|
|
13
|
+
return totals + `<section class="ui-card ui-chart" aria-labelledby="activity-heading"><div class="ui-section-heading"><div><h2 id="activity-heading">${text('Authentication activity')}</h2><p class="ui-muted">${escapeHtml(presentation.textSource('Last 30 days'))}</p></div><span class="ui-badge">UTC</span></div><svg viewBox="0 0 620 190" role="img" aria-labelledby="auth-chart-title auth-chart-description"><title id="auth-chart-title">${text('Authentication activity')}</title><desc id="auth-chart-description">${text('Thirty UTC days of sign-ups, sign-ins and failed sign-ins. Failed sign-ins use a dashed line. Exact values follow in the daily table.')}</desc>${grid}${lines}${labels}</svg><div class="ui-chart-legend"><span><i class="ui-chart-key ui-chart-key-blue" aria-hidden="true"></i>${escapeHtml(presentation.text('copy.signUps'))}</span><span><i class="ui-chart-key ui-chart-key-green" aria-hidden="true"></i>${escapeHtml(presentation.text('copy.signIns'))}</span><span><i class="ui-chart-key ui-chart-key-red" aria-hidden="true"></i>${escapeHtml(presentation.text('copy.failedSignIns'))}</span></div></section>`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AuthService, AuthUser, AuthPrincipal, PresentationContext } from '@jimhoyd/urlcode-auth';
|
|
2
|
+
import type { Screen } from './admin-ui.ts';
|
|
3
|
+
export declare function accountDetail(input: {
|
|
4
|
+
user: AuthUser;
|
|
5
|
+
principal: AuthPrincipal;
|
|
6
|
+
mount: string;
|
|
7
|
+
csrf: string;
|
|
8
|
+
presentation: PresentationContext;
|
|
9
|
+
sessions?: Awaited<ReturnType<AuthService['listSessions']>>;
|
|
10
|
+
activity?: Awaited<ReturnType<AuthService['listAudit']>>;
|
|
11
|
+
notes?: Awaited<ReturnType<AuthService['listAudit']>>;
|
|
12
|
+
operations: boolean;
|
|
13
|
+
recovery?: boolean;
|
|
14
|
+
}): Screen;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { escapeHtml, icon, emptyState, hiddenField, postForm } from '@jimhoyd/urlcode-ui';
|
|
2
|
+
import { formField, hasPermission } from '@jimhoyd/urlcode-auth';
|
|
3
|
+
import { adminTime } from "./admin-screens.js";
|
|
4
|
+
import { markup } from "./admin-ui.js";
|
|
5
|
+
export function accountDetail(input) {
|
|
6
|
+
const { user, principal, mount, csrf, presentation, sessions, activity, notes } = input;
|
|
7
|
+
const plain = (source) => presentation.textSource(source), text = (source) => escapeHtml(plain(source)), value = escapeHtml;
|
|
8
|
+
const url = (path) => escapeHtml(mount + path), id = encodeURIComponent(user.id), field = (name, label) => formField(name, plain(label));
|
|
9
|
+
const form = (path, fields, label, destructive = false) => postForm({ action: mount + path, csrf, fields: hiddenField('accountId', user.id) + fields, label: plain(label), destructive, className: 'ui-form-grid' });
|
|
10
|
+
const sections = [['overview', 'Overview'], ['methods', 'Sign-in methods'], ['sessions', 'Sessions'], ['recovery', 'Recovery'], ['activity', 'Activity'], ['data', 'Data']];
|
|
11
|
+
const empty = (message) => emptyState(plain(message));
|
|
12
|
+
const facts = [['Account ID', user.id], ['Email', user.email], ['Status', user.status], ['Email verified', user.emailVerified ? 'Yes' : 'No'], ['Roles', user.roles.join(', ')], ['Created', new Date(user.created).toISOString()], ['Display name', user.profile?.displayName ?? '—'], ['Language', user.profile?.locale ?? '—']];
|
|
13
|
+
const operations = input.operations && user.id !== principal.id ? `<a class="ui-button-secondary" href="${url('/account-operations?accountId=' + id)}">${text('Account administration')}</a>` : '';
|
|
14
|
+
const overview = `<dl class="ui-definition-grid">${facts.map(([name, content]) => `<dt>${text(name)}</dt><dd>${value(content)}</dd>`).join('')}</dl>` + (hasPermission(principal, 'auth.users.reveal') ? `<details><summary>${text('Reveal email address')}</summary>${form('/users/reveal', field('reason', 'Reason'), 'Reveal email address')}</details>` : '') + (notes ? `<h3>${text('Administrator notes')}</h3>${notes.events.length ? `<ul class="ui-list">${notes.events.map(note => `<li><p>${value(note.reason)}</p><small class="ui-muted">${value(note.actor)} · ${adminTime(note.created)}</small></li>`).join('')}</ul>` : empty('No administrator notes yet.')}` : '') + (hasPermission(principal, 'auth.users.manage') ? `<details><summary>${text('Save administrator note')}</summary><p class="ui-muted">${text('Notes are recorded in the audit trail. Do not include credentials or recovery secrets.')}</p>${form('/users/note', field('reason', 'Administrator note'), 'Save administrator note')}</details>` : '');
|
|
15
|
+
const sessionBody = sessions ? (sessions.length ? `<ul class="ui-list">${sessions.map(session => `<li><strong>${value(session.deviceLabel ?? plain('Unknown device'))}</strong><dl class="ui-definition-grid"><dt>${text('Created')}</dt><dd>${adminTime(session.created)}</dd><dt>${text('Expires')}</dt><dd>${adminTime(session.expires)}</dd></dl><details><summary>${text('Revoke this session')}</summary>${form('/sessions/revoke-one', hiddenField('sessionId', session.id) + field('reason', 'Reason'), 'Revoke this session', true)}</details></li>`).join('')}</ul><details class="ui-danger-zone"><summary>${text('Revoke all sessions')}</summary><p class="ui-muted">${text('The user can sign in again unless their account is locked.')}</p>${form('/sessions/revoke', field('reason', 'Reason'), 'Revoke all sessions', true)}</details>` : empty('No active sessions match these filters.')) : empty('Session management permission is required.');
|
|
16
|
+
const activityBody = activity ? (activity.events.length ? `<ul class="ui-list">${activity.events.map(event => `<li><code>${value(event.action)}</code><p class="ui-muted">${adminTime(event.created)} · ${value(event.actor)}</p></li>`).join('')}</ul>` : empty('No account activity recorded.')) + `<a class="ui-button-secondary" href="${url('/audit?subject=' + id)}">${text('View account audit')}</a>` : empty('Audit permission is required.');
|
|
17
|
+
const recoveryLinks = hasPermission(principal, 'auth.cases.read') ? `<div class="ui-actions">${input.recovery ? `<a class="ui-button-secondary" href="${url('/recovery-cases')}">${text('Manual account recovery')}</a>` : ''}<a class="ui-button-secondary" href="${url('/cases')}">${text('Security cases')}</a></div>` : '';
|
|
18
|
+
const bodies = {
|
|
19
|
+
overview,
|
|
20
|
+
methods: `<dl class="ui-definition-grid"><dt>${text('Authenticator enabled')}</dt><dd>${text(user.totpEnabled ? 'Yes' : 'No')}</dd><dt>${text('Passkey second factor enabled')}</dt><dd>${text(user.passkeyMfaEnabled ? 'Yes' : 'No')}</dd></dl>` + operations,
|
|
21
|
+
sessions: sessionBody,
|
|
22
|
+
recovery: `<p class="ui-muted">${text('Recovery contacts are not supported in this release. Manual evidence requires independent operator assessment.')}</p>` + recoveryLinks,
|
|
23
|
+
activity: activityBody,
|
|
24
|
+
data: `<h3>${text('Consent record')}</h3>${user.profile?.terms ? `<pre>${value(JSON.stringify(user.profile.terms, null, 2))}</pre>` : empty('No consent record is stored for this account.')}` + (hasPermission(principal, 'auth.users.export') ? `<details><summary>${icon('download')}<span>${text('Export account data')}</span></summary>${form('/users/export', field('reason', 'Reason'), 'Export account data')}</details>` : '') + operations,
|
|
25
|
+
};
|
|
26
|
+
return { name: 'admin/user-detail', view: { backHref: mount + '/users', backIcon: markup(icon('arrow-left')), backLabel: plain('Back to users'), status: user.status, sectionsLabel: plain('Account sections'), sections: sections.map(([key, label]) => ({ href: '#detail-' + key, label: plain(label) })), panels: sections.map(([key, label]) => ({ id: key, heading: plain(label), content: markup(bodies[key]) })) } };
|
|
27
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Operator-supplied observations only. This adapter never fetches project URLs or exposes provider errors. */
|
|
2
|
+
export type HealthStatus = 'healthy' | 'degraded' | 'unavailable' | 'unknown';
|
|
3
|
+
export interface AdminHealthSnapshot {
|
|
4
|
+
checkedAt: string;
|
|
5
|
+
runtime: {
|
|
6
|
+
status: HealthStatus;
|
|
7
|
+
readiness: HealthStatus;
|
|
8
|
+
version: string;
|
|
9
|
+
routes: number;
|
|
10
|
+
};
|
|
11
|
+
sender: HealthStatus;
|
|
12
|
+
providers: readonly {
|
|
13
|
+
id: string;
|
|
14
|
+
status: HealthStatus;
|
|
15
|
+
}[];
|
|
16
|
+
alerts: readonly ('sender-failed' | 'provider-expiring' | 'presentation-outdated' | 'translation-incomplete')[];
|
|
17
|
+
}
|
|
18
|
+
export type AdminHealthProvider = (context: {
|
|
19
|
+
signal: AbortSignal;
|
|
20
|
+
}) => Promise<AdminHealthSnapshot>;
|
|
21
|
+
/** Validate and copy only the allowed fields: arbitrary errors, URLs, credentials and metadata never cross this boundary. */
|
|
22
|
+
export declare function validateHealthSnapshot(value: AdminHealthSnapshot): AdminHealthSnapshot;
|
|
23
|
+
/** Bound concurrent operator callbacks even when an adapter ignores cancellation. */
|
|
24
|
+
export declare function createHealthReader(provider: AdminHealthProvider): () => Promise<AdminHealthSnapshot | null>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { withDeadline } from '@jimhoyd/urlcode-ui';
|
|
2
|
+
const statuses = new Set(['healthy', 'degraded', 'unavailable', 'unknown']);
|
|
3
|
+
const alerts = new Set(['sender-failed', 'provider-expiring', 'presentation-outdated', 'translation-incomplete']);
|
|
4
|
+
/** Validate and copy only the allowed fields: arbitrary errors, URLs, credentials and metadata never cross this boundary. */
|
|
5
|
+
export function validateHealthSnapshot(value) {
|
|
6
|
+
if (!value || typeof value.checkedAt !== 'string' || !Number.isFinite(Date.parse(value.checkedAt)) || new Date(value.checkedAt).toISOString() !== value.checkedAt || !value.runtime || !statuses.has(value.runtime.status) || !statuses.has(value.runtime.readiness) || typeof value.runtime.version !== 'string' || !/^[A-Za-z0-9.+_-]{1,64}$/.test(value.runtime.version) || !Number.isSafeInteger(value.runtime.routes) || value.runtime.routes < 0 || value.runtime.routes > 1000000 || !statuses.has(value.sender) || !Array.isArray(value.providers) || value.providers.length > 20 || !Array.isArray(value.alerts) || value.alerts.length > 20)
|
|
7
|
+
throw new Error('Invalid health snapshot');
|
|
8
|
+
const ids = new Set();
|
|
9
|
+
const providers = value.providers.map(provider => {
|
|
10
|
+
if (!provider || typeof provider.id !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(provider.id) || ids.has(provider.id) || !statuses.has(provider.status))
|
|
11
|
+
throw new Error('Invalid provider health');
|
|
12
|
+
ids.add(provider.id);
|
|
13
|
+
return { id: provider.id, status: provider.status };
|
|
14
|
+
});
|
|
15
|
+
if (value.alerts.some(alert => !alerts.has(alert)))
|
|
16
|
+
throw new Error('Invalid health alert');
|
|
17
|
+
return { checkedAt: value.checkedAt, runtime: { status: value.runtime.status, readiness: value.runtime.readiness, version: value.runtime.version, routes: value.runtime.routes }, sender: value.sender, providers, alerts: [...new Set(value.alerts)] };
|
|
18
|
+
}
|
|
19
|
+
/** Bound concurrent operator callbacks even when an adapter ignores cancellation. */
|
|
20
|
+
export function createHealthReader(provider) {
|
|
21
|
+
let active = false;
|
|
22
|
+
return async () => {
|
|
23
|
+
if (active)
|
|
24
|
+
return null;
|
|
25
|
+
active = true;
|
|
26
|
+
try {
|
|
27
|
+
return await withDeadline(signal => Promise.resolve().then(() => provider({ signal })).then(validateHealthSnapshot).finally(() => { active = false; }), 2000, 'Health observation timed out');
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { escapeHtml } from '@jimhoyd/urlcode-ui';
|
|
2
|
+
import { pageResponse } from '@jimhoyd/urlcode-auth';
|
|
3
|
+
/** The console shell for the primitive path only; the kit renders its own from `layout: 'application'`. */
|
|
4
|
+
function primitiveShell(title, sidebar, content, presentation) {
|
|
5
|
+
const label = escapeHtml(presentation?.textSource(title) ?? title);
|
|
6
|
+
return `<div class="ui-shell">${sidebar}<div class="ui-content" id="admin-content" tabindex="-1"><header class="ui-page-header"><h1>${label}</h1></header>${content}</div></div>`;
|
|
7
|
+
}
|
|
8
|
+
/** Trusted admin composition only; the reusable UI package has no knowledge of this shell. */
|
|
9
|
+
export function adminPage(...args) {
|
|
10
|
+
const [title, content, status, headers, script, presentation] = args;
|
|
11
|
+
const boundary = content.startsWith('<aside class="ui-sidebar"') ? content.indexOf('</aside>') + 8 : 0;
|
|
12
|
+
if (boundary < 8)
|
|
13
|
+
return pageResponse(...args);
|
|
14
|
+
const body = primitiveShell(title, content.slice(0, boundary), content.slice(boundary), presentation);
|
|
15
|
+
const response = pageResponse(title, body, status, headers, script, presentation, args[6], 'application');
|
|
16
|
+
// Only trusted document structure is adjusted. Preserve CSP nonces, theme scripts and headers verbatim.
|
|
17
|
+
let html = new TextDecoder().decode(response.body);
|
|
18
|
+
html = html.replace('href="#main"', 'href="#admin-content"').replace(/<h1>[^]*?<\/h1>/, '');
|
|
19
|
+
return { ...response, body: new TextEncoder().encode(html) };
|
|
20
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ScreenOptions } from './admin-ui.ts';
|
|
2
|
+
import type { ExtensionRequest, ExtensionInstance } from '@jimhoyd/urlcode/extensions';
|
|
3
|
+
import { AuthHttp } from '@jimhoyd/urlcode-auth';
|
|
4
|
+
import type { AuthPrincipal, ManualRecoveryService, ManualRecoveryDelivery } from '@jimhoyd/urlcode-auth';
|
|
5
|
+
export interface AdminRecoveryOptions {
|
|
6
|
+
service: ManualRecoveryService & {
|
|
7
|
+
closeCase(input: {
|
|
8
|
+
actorToken: string;
|
|
9
|
+
caseId: string;
|
|
10
|
+
reason: string;
|
|
11
|
+
}): Promise<unknown>;
|
|
12
|
+
addCaseNote(input: {
|
|
13
|
+
actorToken: string;
|
|
14
|
+
caseId: string;
|
|
15
|
+
note: string;
|
|
16
|
+
}): Promise<unknown>;
|
|
17
|
+
};
|
|
18
|
+
sendRecovery?: (message: ManualRecoveryDelivery) => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/** The service is the authorization boundary; this handler supplies CSRF and delivery gates. */
|
|
21
|
+
export declare function createAdminRecovery(options: AdminRecoveryOptions, http: AuthHttp, mount: string): {
|
|
22
|
+
enabled: () => any;
|
|
23
|
+
handle(request: ExtensionRequest, principal: AuthPrincipal, actorToken: string, render: ScreenOptions): Promise<Awaited<ReturnType<ExtensionInstance["handle"]>> | undefined>;
|
|
24
|
+
};
|