@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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { hiddenField, postForm, withDeadline } from '@jimhoyd/urlcode-ui';
|
|
2
|
+
import { maskEmail } from "./admin-reporting.js";
|
|
3
|
+
import { markup, screenResponse } from "./admin-ui.js";
|
|
4
|
+
import { AuthHttp, AuthHttpError, formField, hasPermission, jsonResponse, readFields, wantsJson } from '@jimhoyd/urlcode-auth';
|
|
5
|
+
/** The service is the authorization boundary; this handler supplies CSRF and delivery gates. */
|
|
6
|
+
export function createAdminRecovery(options, http, mount) {
|
|
7
|
+
let delivering = 0;
|
|
8
|
+
const enabled = () => options.service.getManualRecoveryEnabled() && Boolean(options.sendRecovery);
|
|
9
|
+
const hidden = (id) => hiddenField('caseId', id);
|
|
10
|
+
const form = (path, csrf, fields, label) => postForm({ action: mount + path, csrf, fields, label, destructive: path === '/recovery-cases/approve', className: 'ui-form-grid' });
|
|
11
|
+
return { enabled, async handle(request, principal, actorToken, render) {
|
|
12
|
+
const { presentation } = render, tr = (key) => presentation.text('manualRecovery.' + key);
|
|
13
|
+
const path = request.path.slice(mount.length);
|
|
14
|
+
if (!['/recovery-cases', '/recovery-cases/create', '/recovery-cases/approve', '/recovery-cases/note', '/recovery-cases/close'].includes(path))
|
|
15
|
+
return;
|
|
16
|
+
if (!enabled())
|
|
17
|
+
throw new AuthHttpError(404, 'Not found');
|
|
18
|
+
if (principal.impersonatorId || !hasPermission(principal, 'auth.cases.read'))
|
|
19
|
+
throw new AuthHttpError(403, 'Permission required');
|
|
20
|
+
const csrf = http.token(actorToken);
|
|
21
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
22
|
+
if (path !== '/recovery-cases')
|
|
23
|
+
throw new AuthHttpError(405, 'POST required');
|
|
24
|
+
const result = await options.service.listRecoveryCases({ limit: 50, ...(request.query.get('after') ? { after: request.query.get('after') } : {}) });
|
|
25
|
+
if (wantsJson(request))
|
|
26
|
+
return jsonResponse(200, { ...result, cases: result.cases.map(item => ({ ...item, recovery: { ...item.recovery, email: maskEmail(item.recovery.email) } })), csrf });
|
|
27
|
+
const canManage = hasPermission(principal, 'auth.cases.manage');
|
|
28
|
+
const cases = result.cases.map(item => ({ id: item.id, facts: [{ term: tr('account'), value: item.accountId }, { term: tr('address'), value: maskEmail(item.recovery.email) }, { term: tr('status'), value: tr('state.' + item.recovery.state) }, { term: tr('evidence'), value: item.recovery.evidence.summary }, { term: tr('reference'), value: item.recovery.evidence.reference || tr('none') }], reason: item.reason, notes: (item.notes ?? []).map(note => ({ actor: note.actorId, note: note.note })), forms: markup((canManage && ['review', 'delivery', 'ready'].includes(item.recovery.state) ? form('/recovery-cases/note', csrf, hidden(item.id) + formField('reason', tr('note')), tr('addNote')) : '') + (canManage && ['review', 'delivery', 'ready'].includes(item.recovery.state) ? form('/recovery-cases/close', csrf, hidden(item.id) + formField('reason', tr('closure')), tr('close')) : '') + (canManage && item.status === 'pending' && item.makerId !== principal.id ? form('/recovery-cases/approve', csrf, hidden(item.id) + formField('reason', tr('approval')) + formField('confirmation', tr('confirmation')), tr('approve')) : '')) }));
|
|
29
|
+
return screenResponse(tr('title'), { name: 'admin/recovery-cases', view: { intro: tr('intro'), cases, empty: presentation.textSource('No manual recovery cases to review.'), create: canManage ? { summary: tr('create'), form: markup(form('/recovery-cases/create', csrf, formField('accountId', tr('accountId')) + formField('email', tr('verifiedAddress'), 'email') + formField('summary', tr('boundedEvidence')) + formField('reference', tr('reference'), 'text', 'off', false) + formField('reason', tr('reason')), tr('create'))) } : null, nextHref: result.next ? mount + '/recovery-cases?after=' + encodeURIComponent(result.next) : null, nextLabel: presentation.text('action.next') } }, render);
|
|
30
|
+
}
|
|
31
|
+
if (request.method !== 'POST')
|
|
32
|
+
throw new AuthHttpError(405, 'GET, HEAD or POST required');
|
|
33
|
+
if (!hasPermission(principal, 'auth.cases.manage'))
|
|
34
|
+
throw new AuthHttpError(403, 'Permission required');
|
|
35
|
+
const fields = readFields(request, ['accountId', 'email', 'summary', 'reference', 'reason', 'caseId', 'confirmation']);
|
|
36
|
+
http.verify(request, fields);
|
|
37
|
+
if (!fields.reason?.trim() || fields.reason.length > 256)
|
|
38
|
+
throw new AuthHttpError(400, 'A reason is required');
|
|
39
|
+
if (path === '/recovery-cases/create')
|
|
40
|
+
await options.service.createRecoveryCase({ actorToken, accountId: fields.accountId || '', email: fields.email || '', evidence: { summary: fields.summary || '', ...(fields.reference ? { reference: fields.reference } : {}) }, reason: fields.reason });
|
|
41
|
+
else if (path === '/recovery-cases/note')
|
|
42
|
+
await options.service.addCaseNote({ actorToken, caseId: fields.caseId || '', note: fields.reason });
|
|
43
|
+
else if (path === '/recovery-cases/close')
|
|
44
|
+
await options.service.closeCase({ actorToken, caseId: fields.caseId || '', reason: fields.reason });
|
|
45
|
+
else if (path === '/recovery-cases/approve') {
|
|
46
|
+
if (fields.confirmation !== 'RESTORE')
|
|
47
|
+
throw new AuthHttpError(400, 'Typed confirmation must be RESTORE');
|
|
48
|
+
if (delivering >= 4)
|
|
49
|
+
throw new AuthHttpError(503, 'Recovery delivery is busy');
|
|
50
|
+
delivering++;
|
|
51
|
+
let callbackStarted = false, released = false;
|
|
52
|
+
const release = () => { if (!released) {
|
|
53
|
+
released = true;
|
|
54
|
+
delivering--;
|
|
55
|
+
} };
|
|
56
|
+
let issued;
|
|
57
|
+
try {
|
|
58
|
+
issued = await options.service.approveRecoveryCase({ actorToken, caseId: fields.caseId || '', reason: fields.reason });
|
|
59
|
+
const { email, oldEmail, token, case: { id: caseId } } = issued;
|
|
60
|
+
callbackStarted = true;
|
|
61
|
+
await withDeadline(signal => { const sending = Promise.resolve().then(() => options.sendRecovery({ email, oldEmail, token, caseId, signal })); void sending.finally(release).catch(() => { }); return sending; }, 5000, 'Recovery delivery timed out');
|
|
62
|
+
await options.service.activateRecoveryCase({ actorToken, caseId: issued.case.id, token: issued.token });
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (issued)
|
|
66
|
+
await options.service.cancelRecoveryCredential({ actorToken, caseId: issued.case.id, token: issued.token }).catch(() => { });
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
if (!callbackStarted)
|
|
71
|
+
release();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else
|
|
75
|
+
throw new AuthHttpError(404, 'Not found');
|
|
76
|
+
return wantsJson(request) ? jsonResponse(200, { updated: true }) : jsonResponse(303, { updated: true }, [['location', mount + '/recovery-cases']]);
|
|
77
|
+
} };
|
|
78
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AuthService, AuthUser, UserQuery } from '@jimhoyd/urlcode-auth';
|
|
2
|
+
export declare function maskEmail(email: string): string;
|
|
3
|
+
/** Every field is quoted; spreadsheet formula triggers are made literal text. */
|
|
4
|
+
export declare function csvCell(value: unknown): string;
|
|
5
|
+
export declare function observedLastSeen(user: AuthUser): string;
|
|
6
|
+
export declare function usersCsv(users: readonly AuthUser[]): Uint8Array;
|
|
7
|
+
export declare const userFilterKeys: readonly ["query", "role", "status", "method", "verified", "locale", "createdFrom", "createdTo", "lastSeenFrom", "lastSeenTo", "sort", "direction", "lang"];
|
|
8
|
+
export declare function userFilters(values: URLSearchParams): UserQuery;
|
|
9
|
+
export declare function userFilterFields(values: URLSearchParams, text: (source: string) => string): string;
|
|
10
|
+
export declare function auditFilters(values: URLSearchParams): NonNullable<Parameters<AuthService['listAudit']>[0]>;
|
|
11
|
+
export declare function nextPage(path: string, query: URLSearchParams, after: string, allowed: readonly string[]): string;
|
|
12
|
+
export declare function selectedNames(body: Uint8Array, contentType: string | null): string[];
|
|
13
|
+
export declare function selectedAccounts(fields: Record<string, string>): string[];
|
|
14
|
+
export declare function sessionFilters(values: URLSearchParams): NonNullable<Parameters<AuthService['listAllSessions']>[0]>;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { escapeHtml } from '@jimhoyd/urlcode-ui';
|
|
2
|
+
import { hiddenField as hidden } from '@jimhoyd/urlcode-ui';
|
|
3
|
+
import { AuthHttpError, validateUserQuery } from '@jimhoyd/urlcode-auth';
|
|
4
|
+
export function maskEmail(email) { const at = email.lastIndexOf('@'); return at < 1 ? '***' : [...email][0] + '***' + email.slice(at); }
|
|
5
|
+
/** Every field is quoted; spreadsheet formula triggers are made literal text. */
|
|
6
|
+
export function csvCell(value) {
|
|
7
|
+
const text = String(value ?? ''), safe = /^[\s\uFEFF]*[=+\-@]/u.test(text) || /^[\t\r\n]/u.test(text) ? "'" + text : text;
|
|
8
|
+
return '"' + safe.replaceAll('"', '""') + '"';
|
|
9
|
+
}
|
|
10
|
+
export function observedLastSeen(user) {
|
|
11
|
+
const value = 'observedLastSeen' in user ? user.observedLastSeen : undefined;
|
|
12
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= 8640000000000000 ? new Date(value).toISOString() : '';
|
|
13
|
+
}
|
|
14
|
+
export function usersCsv(users) {
|
|
15
|
+
if (users.length > 50)
|
|
16
|
+
throw new AuthHttpError(400, 'Export exceeds one page');
|
|
17
|
+
const rows = [['id', 'email_masked', 'status', 'roles', 'created_utc', 'email_verified', 'display_name', 'locale', 'observed_last_seen_utc'], ...users.map(user => [user.id, maskEmail(user.email), user.status, user.roles.join(';'), new Date(user.created).toISOString(), user.emailVerified, user.profile?.displayName ?? '', user.profile?.locale ?? '', observedLastSeen(user)])];
|
|
18
|
+
return new TextEncoder().encode(rows.map(row => row.map(csvCell).join(',')).join('\r\n') + '\r\n');
|
|
19
|
+
}
|
|
20
|
+
function one(values, key) {
|
|
21
|
+
const found = values.getAll(key);
|
|
22
|
+
if (found.length > 1)
|
|
23
|
+
throw new AuthHttpError(400, 'Duplicate filter');
|
|
24
|
+
return found[0] || undefined;
|
|
25
|
+
}
|
|
26
|
+
export const userFilterKeys = ['query', 'role', 'status', 'method', 'verified', 'locale', 'createdFrom', 'createdTo', 'lastSeenFrom', 'lastSeenTo', 'sort', 'direction', 'lang'];
|
|
27
|
+
function utc(value) {
|
|
28
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z$/.test(value))
|
|
29
|
+
throw new AuthHttpError(400, 'Use a UTC date and time ending in Z');
|
|
30
|
+
const time = Date.parse(value);
|
|
31
|
+
if (!Number.isSafeInteger(time) || time < 0 || new Date(time).toISOString().replace('.000Z', 'Z') !== (value.length === 17 ? value.slice(0, -1) + ':00Z' : value))
|
|
32
|
+
throw new AuthHttpError(400, 'Invalid time');
|
|
33
|
+
return time;
|
|
34
|
+
}
|
|
35
|
+
export function userFilters(values) {
|
|
36
|
+
const input = { limit: 50 };
|
|
37
|
+
for (const key of values.keys())
|
|
38
|
+
if (![...userFilterKeys, 'after'].includes(key))
|
|
39
|
+
throw new AuthHttpError(400, 'Unknown user filter');
|
|
40
|
+
for (const key of ['query', 'role', 'status', 'method', 'locale', 'sort', 'direction', 'after']) {
|
|
41
|
+
const value = one(values, key);
|
|
42
|
+
if (value)
|
|
43
|
+
Object.assign(input, { [key]: value });
|
|
44
|
+
}
|
|
45
|
+
const verified = one(values, 'verified');
|
|
46
|
+
if (verified !== undefined) {
|
|
47
|
+
if (!['true', 'false'].includes(verified))
|
|
48
|
+
throw new AuthHttpError(400, 'Invalid verification filter');
|
|
49
|
+
input.verified = verified === 'true';
|
|
50
|
+
}
|
|
51
|
+
for (const key of ['createdFrom', 'createdTo', 'lastSeenFrom', 'lastSeenTo']) {
|
|
52
|
+
const value = one(values, key);
|
|
53
|
+
if (value)
|
|
54
|
+
input[key] = utc(value);
|
|
55
|
+
}
|
|
56
|
+
one(values, 'lang');
|
|
57
|
+
try {
|
|
58
|
+
return validateUserQuery(input);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new AuthHttpError(400, 'Invalid user filters or cursor; restart the search');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function userFilterFields(values, text) {
|
|
65
|
+
const label = (source) => escapeHtml(text(source));
|
|
66
|
+
const input = (key, title, placeholder = '') => `<label>${label(title)}<input name="${key}" value="${escapeHtml(one(values, key) || '')}"${placeholder ? ` placeholder="${label(placeholder)}"` : ''} autocomplete="off"></label>`;
|
|
67
|
+
const select = (key, title, items, fallback = '') => `<label>${label(title)}<select name="${key}">${items.map(([value, title]) => `<option value="${value}"${(one(values, key) || fallback) === value ? ' selected' : ''}>${label(title)}</option>`).join('')}</select></label>`;
|
|
68
|
+
const advancedKeys = ['role', 'method', 'verified', 'locale', 'createdFrom', 'createdTo', 'lastSeenFrom', 'lastSeenTo', 'sort', 'direction'];
|
|
69
|
+
const expanded = advancedKeys.some(key => { const value = one(values, key); return Boolean(value) && !(key === 'sort' && value === 'id') && !(key === 'direction' && value === 'asc'); });
|
|
70
|
+
return '<div class="ui-toolbar">' + input('query', 'Search accounts', 'Email, name or account ID') +
|
|
71
|
+
select('status', 'Status', [['', 'Any status'], ['active', 'Active'], ['locked', 'Locked'], ['pending-delete', 'Pending deletion']]) + '</div>' +
|
|
72
|
+
`<details class="ui-filter"${expanded ? ' open' : ''}><summary>${label('Advanced filters')}</summary><div class="ui-form-grid">` + input('role', 'Role name') +
|
|
73
|
+
select('method', 'Stored credential method', [['', 'Any stored credential method'], ['password', 'Password'], ['passkey', 'Passkey'], ['oidc', 'External identity']]) +
|
|
74
|
+
select('verified', 'Email verification', [['', 'Any verification'], ['true', 'Verified'], ['false', 'Unverified']]) + input('locale', 'Locale') +
|
|
75
|
+
input('createdFrom', 'Created from UTC') + input('createdTo', 'Created to UTC') + input('lastSeenFrom', 'Last seen from UTC') + input('lastSeenTo', 'Last seen to UTC') +
|
|
76
|
+
select('sort', 'Sort by', [['id', 'Account ID'], ['email', 'Email address'], ['displayName', 'Display name'], ['created', 'Created'], ['lastSeen', 'Last seen']], 'id') +
|
|
77
|
+
select('direction', 'Direction', [['asc', 'Ascending'], ['desc', 'Descending']], 'asc') + '</div></details>' +
|
|
78
|
+
(one(values, 'lang') ? hidden('lang', one(values, 'lang')) : '');
|
|
79
|
+
}
|
|
80
|
+
export function auditFilters(values) {
|
|
81
|
+
const result = { limit: 50 };
|
|
82
|
+
for (const name of ['actor', 'subject', 'action', 'after']) {
|
|
83
|
+
const value = one(values, name);
|
|
84
|
+
if (value)
|
|
85
|
+
result[name] = value;
|
|
86
|
+
}
|
|
87
|
+
for (const name of ['from', 'to']) {
|
|
88
|
+
const value = one(values, name);
|
|
89
|
+
if (value)
|
|
90
|
+
result[name] = utc(value);
|
|
91
|
+
}
|
|
92
|
+
if (result.from !== undefined && result.to !== undefined && result.from > result.to)
|
|
93
|
+
throw new AuthHttpError(400, 'Invalid time range');
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
export function nextPage(path, query, after, allowed) {
|
|
97
|
+
const result = new URLSearchParams();
|
|
98
|
+
for (const name of allowed) {
|
|
99
|
+
const value = one(query, name);
|
|
100
|
+
if (value)
|
|
101
|
+
result.set(name, value);
|
|
102
|
+
}
|
|
103
|
+
result.set('after', after);
|
|
104
|
+
return path + '?' + result.toString();
|
|
105
|
+
}
|
|
106
|
+
export function selectedNames(body, contentType) {
|
|
107
|
+
if (body.byteLength > 16384)
|
|
108
|
+
throw new AuthHttpError(413, 'Request body too large');
|
|
109
|
+
let source;
|
|
110
|
+
try {
|
|
111
|
+
source = new TextDecoder('utf-8', { fatal: true }).decode(body);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
throw new AuthHttpError(400, 'Invalid encoding');
|
|
115
|
+
}
|
|
116
|
+
let names;
|
|
117
|
+
if (contentType?.split(';')[0]?.trim() === 'application/json') {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(source);
|
|
120
|
+
names = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? Object.keys(parsed) : [];
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new AuthHttpError(400, 'Invalid JSON');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
names = [...new URLSearchParams(source).keys()];
|
|
128
|
+
return names.filter(name => /^selected\.[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(name));
|
|
129
|
+
}
|
|
130
|
+
export function selectedAccounts(fields) {
|
|
131
|
+
const ids = Object.entries(fields).filter(([name]) => name.startsWith('selected.')).map(([name, value]) => {
|
|
132
|
+
if (value !== 'yes')
|
|
133
|
+
throw new AuthHttpError(400, 'Invalid selection');
|
|
134
|
+
return name.slice(9);
|
|
135
|
+
});
|
|
136
|
+
if (fields.accountIds) {
|
|
137
|
+
if (ids.length)
|
|
138
|
+
throw new AuthHttpError(400, 'Use one selection format');
|
|
139
|
+
ids.push(...fields.accountIds.split(',').map(value => value.trim()).filter(Boolean));
|
|
140
|
+
}
|
|
141
|
+
if (!ids.length || ids.length > 50 || new Set(ids).size !== ids.length)
|
|
142
|
+
throw new AuthHttpError(400, 'Select between one and fifty distinct accounts');
|
|
143
|
+
return ids;
|
|
144
|
+
}
|
|
145
|
+
export function sessionFilters(values) {
|
|
146
|
+
const result = { limit: 50 };
|
|
147
|
+
for (const key of values.keys())
|
|
148
|
+
if (!['accountId', 'device', 'createdFrom', 'createdTo', 'after', 'lang'].includes(key))
|
|
149
|
+
throw new AuthHttpError(400, 'Unknown session filter');
|
|
150
|
+
for (const key of ['accountId', 'device', 'after']) {
|
|
151
|
+
const value = one(values, key);
|
|
152
|
+
if (value)
|
|
153
|
+
result[key] = value;
|
|
154
|
+
}
|
|
155
|
+
for (const key of ['createdFrom', 'createdTo']) {
|
|
156
|
+
const value = one(values, key);
|
|
157
|
+
if (value)
|
|
158
|
+
result[key] = utc(value);
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Runtime, RuntimeOptions } from '@jimhoyd/urlcode';
|
|
2
|
+
import type { AuthExtensionOptions } from '@jimhoyd/urlcode-auth';
|
|
3
|
+
import type { AdminExtensionOptions } from './admin.ts';
|
|
4
|
+
import type { SupportBannerOptions } from './support-banner.ts';
|
|
5
|
+
import type { AdminHealthSnapshot } from './admin-health.ts';
|
|
6
|
+
export interface AdministrationRuntimeOptions {
|
|
7
|
+
auth: AuthExtensionOptions;
|
|
8
|
+
admin?: Omit<AdminExtensionOptions, 'service' | 'csrfKey' | 'projectSha256' | 'health'>;
|
|
9
|
+
runtime?: RuntimeOptions;
|
|
10
|
+
banner?: Omit<SupportBannerOptions, 'service' | 'authMount'>;
|
|
11
|
+
observations?: (context: {
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
}) => Promise<Pick<AdminHealthSnapshot, 'sender' | 'providers' | 'alerts'>>;
|
|
14
|
+
}
|
|
15
|
+
/** Trusted host constructor: every returned runtime response passes through support-session enforcement. */
|
|
16
|
+
export declare function createAdministrationRuntime(project: string, options: AdministrationRuntimeOptions): Promise<Runtime>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createRuntime } from '@jimhoyd/urlcode';
|
|
2
|
+
import { authExtension } from '@jimhoyd/urlcode-auth';
|
|
3
|
+
import { adminExtension } from "./admin.js";
|
|
4
|
+
import { withSupportBanner } from "./support-banner.js";
|
|
5
|
+
/** Trusted host constructor: every returned runtime response passes through support-session enforcement. */
|
|
6
|
+
export async function createAdministrationRuntime(project, options) {
|
|
7
|
+
let runtime;
|
|
8
|
+
const { service, csrfKey, projectSha256 } = options.auth;
|
|
9
|
+
const admin = adminExtension({ ...options.admin, service, csrfKey, projectSha256, health: async (context) => {
|
|
10
|
+
const observed = options.observations ? await options.observations(context) : { sender: 'unknown', providers: [], alerts: [] };
|
|
11
|
+
const healthy = runtime?.healthy === true;
|
|
12
|
+
return { ...observed, checkedAt: new Date().toISOString(), runtime: { status: healthy ? 'healthy' : 'unavailable', readiness: healthy ? 'healthy' : 'unavailable', version: runtime?.version ?? 'unknown', routes: runtime?.count ?? 0 } };
|
|
13
|
+
} });
|
|
14
|
+
runtime = await createRuntime(project, { ...options.runtime, extensions: [...(options.runtime?.extensions ?? []), authExtension(options.auth), admin] });
|
|
15
|
+
try {
|
|
16
|
+
return withSupportBanner(runtime, { ...options.banner, service, authMount: options.admin?.authMount ?? '/account' });
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
await runtime.close();
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AuthService, AuthPrincipal, PresentationContext } from '@jimhoyd/urlcode-auth';
|
|
2
|
+
import type { AdminHealthSnapshot } from './admin-health.ts';
|
|
3
|
+
import type { Screen } from './admin-ui.ts';
|
|
4
|
+
interface Input {
|
|
5
|
+
mount: string;
|
|
6
|
+
csrf: string;
|
|
7
|
+
principal: AuthPrincipal;
|
|
8
|
+
presentation: PresentationContext;
|
|
9
|
+
query: URLSearchParams;
|
|
10
|
+
}
|
|
11
|
+
export declare function adminTime(value: number | string): string;
|
|
12
|
+
export declare function rolesScreen(input: Input & {
|
|
13
|
+
roles: ReturnType<AuthService['getRoles']>;
|
|
14
|
+
}): Screen;
|
|
15
|
+
export declare function sessionsScreen(input: Input & {
|
|
16
|
+
result: Awaited<ReturnType<AuthService['listAllSessions']>>;
|
|
17
|
+
}): Screen;
|
|
18
|
+
export declare function auditScreen(input: Input & {
|
|
19
|
+
result: Awaited<ReturnType<AuthService['listAudit']>>;
|
|
20
|
+
}): Screen;
|
|
21
|
+
export declare function healthScreen(input: Input & {
|
|
22
|
+
health: AdminHealthSnapshot | null;
|
|
23
|
+
configured: boolean;
|
|
24
|
+
}): Screen;
|
|
25
|
+
export declare function casesScreen(input: Input & {
|
|
26
|
+
result: Awaited<ReturnType<AuthService['listCases']>>;
|
|
27
|
+
}): Screen;
|
|
28
|
+
export declare function registrationsScreen(input: Input & {
|
|
29
|
+
result: Awaited<ReturnType<AuthService['listRegistrationRequests']>>;
|
|
30
|
+
canInvite: boolean;
|
|
31
|
+
}): Screen;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { escapeHtml, field, icon, hiddenField, postForm } from '@jimhoyd/urlcode-ui';
|
|
2
|
+
import { hasPermission } from '@jimhoyd/urlcode-auth';
|
|
3
|
+
import { maskEmail, nextPage } from "./admin-reporting.js";
|
|
4
|
+
import { markup } from "./admin-ui.js";
|
|
5
|
+
/** Escaped helpers build the `Markup` slots; plain `text`/`tr` values go into the view and the renderer escapes them. */
|
|
6
|
+
function tools(input) {
|
|
7
|
+
const text = (source) => input.presentation.textSource(source);
|
|
8
|
+
const tr = (key) => input.presentation.text(key);
|
|
9
|
+
const html = (source) => escapeHtml(text(source));
|
|
10
|
+
const url = (path) => escapeHtml(input.mount + path);
|
|
11
|
+
const filter = (name, label, required = false) => field({ name, label: text(label), required, value: input.query.get(name) || '' });
|
|
12
|
+
const form = (path, body, label, destructive = false) => postForm({ action: input.mount + path, csrf: input.csrf, fields: body, label: text(label), destructive, className: 'ui-form-grid' });
|
|
13
|
+
const reason = () => field({ name: 'reason', label: text('Reason'), description: text('Sensitive actions require a recent sign-in and a reason.') });
|
|
14
|
+
const table = (caption, headings, rows) => `<div class="ui-table-wrap" tabindex="0" role="region" aria-label="${escapeHtml(caption)}"><table class="ui-table"><caption>${escapeHtml(caption)}</caption><thead><tr>${headings.map(heading => `<th scope="col">${escapeHtml(heading)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
15
|
+
return { text, tr, html, url, filter, form, hidden: hiddenField, reason, table };
|
|
16
|
+
}
|
|
17
|
+
export function adminTime(value) {
|
|
18
|
+
const date = new Date(value);
|
|
19
|
+
return `<time datetime="${escapeHtml(date.toISOString())}">${escapeHtml(date.toISOString().slice(0, 16).replace('T', ' '))} UTC</time>`;
|
|
20
|
+
}
|
|
21
|
+
export function rolesScreen(input) {
|
|
22
|
+
const { text, tr, html, url, form, reason, table } = tools(input);
|
|
23
|
+
const rows = Object.entries(input.roles).map(([name, grants]) => `<tr><th scope="row">${escapeHtml(name)}</th><td>${grants.length ? grants.map(grant => `<code>${escapeHtml(grant)}</code>`).join('<br>') : `<span class="ui-muted">${html('No additional permissions')}</span>`}</td><td>${hasPermission(input.principal, 'auth.users.read') ? `<a href="${url('/users?role=' + encodeURIComponent(name))}">${escapeHtml(tr('nav.users'))}</a>` : ''}</td></tr>`).join('');
|
|
24
|
+
return { name: 'admin/roles', view: { intro: text('Review the permissions each role grants. Assign reviewed roles to an account below.'), note: tr('copy.roleDefinitionsAreReadOnlyHere'), table: markup(table(tr('nav.roles'), [text('Role name'), text('Permissions'), text('Accounts')], rows)), assign: hasPermission(input.principal, 'auth.users.manage') ? { heading: text('Assign roles'), form: markup(form('/users/roles', field({ name: 'accountId', label: text('Account ID') }) + field({ name: 'roles', label: text('Role names separated by commas') }) + reason(), 'Assign roles')) } : null } };
|
|
25
|
+
}
|
|
26
|
+
export function sessionsScreen(input) {
|
|
27
|
+
const { text, tr, html, url, form, reason, hidden, filter, table } = tools(input);
|
|
28
|
+
const rows = input.result.sessions.map(session => `<tr><td>${hasPermission(input.principal, 'auth.users.read') ? `<a href="${url('/users/detail?id=' + encodeURIComponent(session.accountId))}">${escapeHtml(maskEmail(session.email))}</a>` : escapeHtml(maskEmail(session.email))}<small class="ui-muted ui-account-id">${escapeHtml(session.accountId)}</small></td><td>${escapeHtml(session.deviceLabel || text('Unknown device'))}</td><td>${adminTime(session.created)}</td><td>${adminTime(session.expires)}</td><td><details><summary>${html('Revoke this session')}</summary><p class="ui-muted">${html('The user can sign in again unless their account is locked.')}</p>${form('/sessions/revoke-one', hidden('sessionId', session.id) + reason(), 'Revoke this session', true)}</details></td></tr>`).join('');
|
|
29
|
+
const accountId = input.query.get('accountId');
|
|
30
|
+
const filters = `<form class="ui-form-grid" method="get" action="${url('/sessions')}">${filter('accountId', 'Account ID')}${filter('device', 'Device')}${filter('createdFrom', 'Created from UTC')}${filter('createdTo', 'Created to UTC')}<div class="ui-actions"><button type="submit">${escapeHtml(tr('action.findSessions'))}</button><a class="ui-button-secondary" href="${url('/sessions')}">${html('Reset filters')}</a></div></form>`;
|
|
31
|
+
return { name: 'admin/sessions', view: { filtersHeading: text('Filter sessions'), intro: text('Review active sessions. Revoking a session signs that device out.'), filters: markup(filters), count: input.presentation.text('message.sessionsOnPage', { count: input.result.sessions.length }), table: rows ? markup(table(tr('nav.sessions'), [text('Account'), text('Device'), text('Created'), text('Expires'), text('Actions')], rows)) : null, empty: text('No active sessions match these filters.'), nextHref: input.result.next ? nextPage(input.mount + '/sessions', input.query, input.result.next, ['accountId', 'device', 'createdFrom', 'createdTo']) : null, nextLabel: tr('action.next'), revokeAll: accountId && input.result.sessions.length ? { summary: text('Revoke all sessions'), help: text('The user can sign in again unless their account is locked.'), form: markup(form('/sessions/revoke', hidden('accountId', accountId) + reason(), 'Revoke all sessions', true)) } : null } };
|
|
32
|
+
}
|
|
33
|
+
export function auditScreen(input) {
|
|
34
|
+
const { text, tr, html, url, filter, hidden, table } = tools(input);
|
|
35
|
+
const filters = () => filter('actor', 'Actor account ID') + filter('subject', 'Subject account ID') + filter('action', 'Event action') + filter('from', 'From UTC (YYYY-MM-DDTHH:mmZ)') + filter('to', 'To UTC (YYYY-MM-DDTHH:mmZ)');
|
|
36
|
+
const rows = input.result.events.map(event => `<tr><td>${adminTime(event.created)}</td><td><code>${escapeHtml(event.action)}</code></td><td>${escapeHtml(event.actor)}</td><td>${escapeHtml(event.subject)}</td></tr>`).join('');
|
|
37
|
+
const form = `<form class="ui-form-grid" method="get" action="${url('/audit')}">${filters()}<div class="ui-actions"><button type="submit">${escapeHtml(tr('copy.filterAudit'))}</button><a class="ui-button-secondary" href="${url('/audit')}">${html('Reset filters')}</a></div></form>`;
|
|
38
|
+
const exportForm = `<form class="ui-form-grid" method="get" action="${url('/audit/export')}">${['actor', 'subject', 'action'].map(key => hidden(key, input.query.get(key) || '')).join('')}${filter('from', 'From UTC (YYYY-MM-DDTHH:mmZ)', true)}${filter('to', 'To UTC (YYYY-MM-DDTHH:mmZ)', true)}<div class="ui-actions"><button class="ui-button-secondary" type="submit">${escapeHtml(tr('action.exportAudit'))}</button></div></form>`;
|
|
39
|
+
return { name: 'admin/audit', view: { filtersHeading: text('Filter audit events'), intro: text('Review recorded security events. Use filters to narrow the time range or account.'), filters: markup(form), table: rows ? markup(table(tr('copy.recentSecurityEvents'), [tr('copy.time'), tr('copy.action'), tr('copy.actor'), tr('copy.subject')], rows)) : null, empty: text('No audit events match these filters.'), nextHref: input.result.next ? nextPage(input.mount + '/audit', input.query, input.result.next, ['actor', 'subject', 'action', 'from', 'to']) : null, nextLabel: tr('action.next'), export: hasPermission(input.principal, 'auth.audit.export') ? { summary: markup(icon('download') + `<span>${html('Export audit events')}</span>`), form: markup(exportForm) } : null } };
|
|
40
|
+
}
|
|
41
|
+
export function healthScreen(input) {
|
|
42
|
+
const { text, tr, table } = tools(input), health = input.health;
|
|
43
|
+
const base = { intro: text('Reported observations from this host and its configured integrations.'), alertsHeading: tr('health.alerts'), noAlerts: text('No alerts reported.') };
|
|
44
|
+
if (!health)
|
|
45
|
+
return { name: 'admin/health', view: { unavailable: input.configured ? tr('health.unavailableStatus') : tr('health.unavailable'), ...base, facts: [], components: markup(''), alerts: [] } };
|
|
46
|
+
const status = (value) => `<span class="ui-badge" data-status="${escapeHtml(value)}">${escapeHtml(tr('health.' + (value === 'unavailable' ? 'unavailableStatus' : value)))}</span>`;
|
|
47
|
+
const components = [[tr('health.runtime'), health.runtime.status], [tr('health.readiness'), health.runtime.readiness], [tr('health.sender'), health.sender], ...health.providers.map(provider => [tr('health.provider') + ': ' + provider.id, provider.status])];
|
|
48
|
+
const messages = { 'sender-failed': 'Notification sender reported a failure.', 'provider-expiring': 'A provider credential is approaching expiry.', 'presentation-outdated': 'The configured presentation needs an update.', 'translation-incomplete': 'Some translations are incomplete.' };
|
|
49
|
+
const rows = components.map(([label, value]) => `<tr><th scope="row">${escapeHtml(label)}</th><td>${status(value)}</td></tr>`).join('');
|
|
50
|
+
return { name: 'admin/health', view: { unavailable: null, ...base, facts: [{ term: tr('health.updated'), value: markup(adminTime(health.checkedAt)) }, { term: tr('health.version'), value: markup(`<code>${escapeHtml(health.runtime.version)}</code>`) }, { term: tr('health.routes'), value: markup(escapeHtml(input.presentation.text('number.value', { value: health.runtime.routes }))) }], components: markup(table(tr('health.component'), [tr('health.component'), tr('health.status')], rows)), alerts: health.alerts.map(alert => text(messages[alert])) } };
|
|
51
|
+
}
|
|
52
|
+
export function casesScreen(input) {
|
|
53
|
+
const { text, tr, html, form, hidden, reason } = tools(input), manage = hasPermission(input.principal, 'auth.cases.manage');
|
|
54
|
+
const cases = input.result.cases.map(item => ({ action: item.action, status: item.status, accountId: item.accountId, reason: item.reason, notes: (item.notes ?? []).map(note => ({ note: note.note, actorId: note.actorId })), actions: markup((manage ? `<details><summary>${html('Add note')}</summary>${form('/cases/note', hidden('caseId', item.id) + reason(), 'Add note')}</details>` : '') + (manage && item.status === 'pending' ? `<details><summary>${html('Close without applying')}</summary>${form('/cases/close', hidden('caseId', item.id) + reason(), 'Close without applying')}</details>` : '') + (manage && item.status === 'pending' && item.makerId !== input.principal.id ? `<details class="ui-danger-zone"><summary>${html('Approve and apply')}</summary>${form('/cases/approve', hidden('caseId', item.id) + reason(), 'Approve and apply', true)}</details>` : '')) }));
|
|
55
|
+
const actions = ['reset-factors', 'lock', 'unlock', 'roles'];
|
|
56
|
+
return { name: 'admin/cases', view: { accountLabel: text('Account ID'), reasonLabel: text('Reason'), cases, empty: text('No support cases to review.'), create: manage ? { summary: text('Create a support case'), form: markup(form('/cases/create', field({ name: 'accountId', label: text('Account ID') }) + `<label>${html('Action: reset-factors, lock, unlock or roles')}<select name="action">${actions.map(action => `<option value="${action}">${escapeHtml(action)}</option>`).join('')}</select></label>` + field({ name: 'roles', label: text('Roles (for roles action)'), required: false }) + reason(), 'Create case for a second administrator')) } : null, nextHref: input.result.next ? input.mount + '/cases?after=' + encodeURIComponent(input.result.next) : null, nextLabel: tr('action.next') } };
|
|
57
|
+
}
|
|
58
|
+
export function registrationsScreen(input) {
|
|
59
|
+
const { text, tr, form, hidden, reason } = tools(input);
|
|
60
|
+
return { name: 'admin/registrations', view: { requests: input.result.requests.map(item => ({ email: maskEmail(item.email), form: markup(form('/registrations/approve', hidden('requestId', item.id) + reason(), 'Approve account')) })), empty: text('No registration requests are waiting for approval.'), nextHref: input.result.next ? input.mount + '/registrations?after=' + encodeURIComponent(input.result.next) : null, nextLabel: tr('action.next'), invite: input.canInvite && hasPermission(input.principal, 'auth.users.create') ? { heading: text('Send invitation'), form: markup(form('/invitations', field({ name: 'email', label: text('Email address'), type: 'email' }) + reason(), 'Send invitation')) } : null } };
|
|
61
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ViewModel } from '@jimhoyd/urlcode-ui';
|
|
2
|
+
export interface AdminTemplate {
|
|
3
|
+
readonly source: string;
|
|
4
|
+
readonly sample: ViewModel;
|
|
5
|
+
}
|
|
6
|
+
/** Template sources with their view model samples, keyed by full template name. */
|
|
7
|
+
export declare const adminTemplates: Readonly<Record<string, AdminTemplate>>;
|
|
8
|
+
export declare const adminTemplateNames: readonly string[];
|
|
9
|
+
/** What the host hands to `createUiExtension({ extensions: [adminUiTemplates] })`. */
|
|
10
|
+
export declare const adminUiTemplates: {
|
|
11
|
+
readonly name: 'admin';
|
|
12
|
+
readonly templates: Readonly<Record<string, string>>;
|
|
13
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The console screens as kit templates, under the `admin` namespace. A
|
|
3
|
+
* template only places a view model the extension computes: every value is
|
|
4
|
+
* escaped by the renderer, and the `Markup` slots (forms with their CSRF
|
|
5
|
+
* field, table rows, charts, icons) are built by trusted package code. A
|
|
6
|
+
* project may shadow any of these by name; it cannot change a flow, what a
|
|
7
|
+
* form validates, which permission gates a control, what is escaped or what
|
|
8
|
+
* a page sends in headers.
|
|
9
|
+
*/
|
|
10
|
+
import { Markup } from '@jimhoyd/urlcode-ui';
|
|
11
|
+
const m = (html) => new Markup(html);
|
|
12
|
+
const declare = (name, body) => `{{!-- viewModel: admin/${name}@1 --}}${body}`;
|
|
13
|
+
const form = () => m('<form method="post"></form>');
|
|
14
|
+
const next = '{{#if nextHref}}<p><a class="ui-button-secondary" href="{{href nextHref}}">{{nextLabel}}</a></p>{{/if}}';
|
|
15
|
+
const empty = (key) => `<section class="ui-card"><p class="ui-empty">{{${key}}}</p></section>`;
|
|
16
|
+
const counts = '<td>{{signUps}}</td><td>{{signIns}}</td><td>{{failedSignIns}}</td>';
|
|
17
|
+
const activityTable = (caption, rows, first, key) => `<div class="ui-table-wrap" tabindex="0" role="region" aria-label="{{activity.${caption}}}"><table class="ui-table"><caption>{{activity.${caption}}}</caption><thead><tr><th scope="col">{{activity.${first}}}</th><th scope="col">{{activity.signUps}}</th><th scope="col">{{activity.signIns}}</th><th scope="col">{{activity.failedSignIns}}</th></tr></thead><tbody>{{#each activity.${rows}}}<tr><th scope="row">{{${key}}}</th>${counts}</tr>{{/each}}</tbody></table></div>`;
|
|
18
|
+
const screens = {
|
|
19
|
+
dashboard: {
|
|
20
|
+
body: `{{#if impersonation}}<details class="ui-card ui-danger-zone"><summary>{{impersonation.summary}}</summary>{{impersonation.form}}</details>{{/if}}{{#if summary}}{{summary}}{{/if}}{{#if activity}}<details class="ui-card"><summary id="daily-heading">{{activity.heading}}</summary>${activityTable('dailyCaption', 'days', 'dayHeading', 'day')}${activityTable('methodsCaption', 'methods', 'methodHeading', 'method')}</details>{{/if}}{{#if recent}}<section class="ui-card"><div class="ui-section-heading"><h2>{{recent.heading}}</h2><a href="{{href recent.auditHref}}">{{recent.auditLabel}}</a></div><ul class="ui-activity">{{#if recent.events}}{{#each recent.events}}<li><span>{{action}}</span><time class="ui-muted" datetime="{{datetime}}">{{label}} UTC</time></li>{{/each}}{{else}}<li class="ui-empty">{{recent.empty}}</li>{{/if}}</ul></section>{{/if}}`,
|
|
21
|
+
sample: { impersonation: { summary: 'Start ten-minute support impersonation', form: form() }, intro: 'Select a section.', summary: m('<div class="ui-metrics"></div>'), activity: { heading: 'Authentication activity, last 30 UTC days', description: 'Recorded account creations, sign-ins and failed sign-ins.', dailyCaption: 'Daily authentication counts', methodsCaption: 'Authentication methods over the same 30 days', dayHeading: 'UTC day', methodHeading: 'Method', signUps: 'Sign-ups', signIns: 'Sign-ins', failedSignIns: 'Failed sign-ins', days: [{ day: '2030-01-01', signUps: '1', signIns: '2', failedSignIns: '0' }], methods: [{ method: 'password', signUps: '1', signIns: '2', failedSignIns: '0' }] }, recent: { heading: 'Recent events', auditHref: '/admin/audit', auditLabel: 'Audit', events: [{ action: 'session.created', datetime: '2030-01-01T00:00:00.000Z', label: '2030-01-01 00:00' }], empty: 'No account activity recorded.' } },
|
|
22
|
+
},
|
|
23
|
+
users: {
|
|
24
|
+
body: `{{#if setup}}<details class="ui-card"><summary>{{setup.summary}}</summary>{{setup.form}}</details>{{/if}}<section class="ui-card ui-filter-card">{{filters}}</section><section class="ui-card"><div class="ui-section-heading"><span class="ui-muted">{{caption}}</span><span class="ui-badge">{{count}}</span></div><div class="ui-table-wrap" tabindex="0" role="region" aria-label="{{caption}}"><table class="ui-table"><caption class="ui-sr-only">{{caption}}</caption><thead><tr><th scope="col">{{columns.select}}</th><th scope="col">{{columns.account}}</th><th scope="col">{{columns.status}}</th><th scope="col">{{columns.lastSeen}}</th><th scope="col">{{columns.roles}}</th><th scope="col">{{columns.actions}}</th></tr></thead><tbody>{{#if rows}}{{rows}}{{else}}<tr><td colspan="6" class="ui-empty">{{empty}}</td></tr>{{/if}}</tbody></table></div><p class="ui-muted">{{caveat}}</p>{{#if nextHref}}<div class="ui-toolbar"><a class="ui-button" href="{{href nextHref}}">{{nextLabel}}</a></div>{{/if}}</section>{{#if bulk}}<details class="ui-card"><summary>{{bulk.summary}}</summary>{{bulk.form}}</details>{{/if}}{{#if exports}}<details class="ui-card"><summary>{{exports.summary}}</summary><div class="ui-form-grid">{{exports.forms}}</div></details>{{/if}}`,
|
|
25
|
+
sample: { setup: { summary: 'Create account and send setup link', form: form() }, filters: m('<form method="get"></form>'), heading: 'Users', count: '1', caption: 'Accounts (email addresses masked)', columns: { select: 'Select', account: 'Account', status: 'Status', lastSeen: 'Last seen', roles: 'Roles', actions: 'Actions' }, rows: m('<tr><td></td><td>a***@example.test</td><td>active</td><td>—</td><td></td><td></td></tr>'), empty: 'No matching accounts. Try changing your filters.', caveat: 'Activity data begins when the feature is activated.', nextHref: '/admin/users?after=cursor', nextLabel: 'Next', bulk: { summary: 'Bulk action', form: form() }, exports: { summary: m('<span>Export accounts</span>'), forms: form() } },
|
|
26
|
+
},
|
|
27
|
+
'user-detail': {
|
|
28
|
+
body: `<div class="ui-toolbar"><a href="{{href backHref}}">{{backIcon}}<span>{{backLabel}}</span></a><span class="ui-badge">{{status}}</span></div><nav class="ui-toolbar" aria-label="{{sectionsLabel}}">{{#each sections}}<a href="{{href href}}">{{label}}</a>{{/each}}</nav>{{#each panels}}<section class="ui-card" id="detail-{{id}}" aria-labelledby="heading-{{id}}"><h2 id="heading-{{id}}">{{heading}}</h2>{{content}}</section>{{/each}}`,
|
|
29
|
+
sample: { backHref: '/admin/users', backIcon: m('<svg aria-hidden="true"></svg>'), backLabel: 'Back to users', status: 'active', sectionsLabel: 'Account sections', sections: [{ href: '#detail-overview', label: 'Overview' }], panels: [{ id: 'overview', heading: 'Overview', content: m('<dl></dl>') }] },
|
|
30
|
+
},
|
|
31
|
+
sessions: {
|
|
32
|
+
body: `<section class="ui-card ui-filter-card">{{filters}}</section><section class="ui-card"><p class="ui-muted">{{count}}</p>{{#if table}}{{table}}{{else}}<p class="ui-empty">{{empty}}</p>{{/if}}${next}</section>{{#if revokeAll}}<details class="ui-card ui-danger-zone"><summary>{{revokeAll.summary}}</summary><p class="ui-muted">{{revokeAll.help}}</p>{{revokeAll.form}}</details>{{/if}}`,
|
|
33
|
+
sample: { filtersHeading: 'Filter sessions', intro: 'Review active sessions. Revoking a session signs that device out.', filters: m('<form method="get"></form>'), count: '1 session on this page', table: m('<div class="ui-table-wrap"></div>'), empty: 'No active sessions match these filters.', nextHref: null, nextLabel: 'Next', revokeAll: { summary: 'Revoke all sessions', help: 'The user can sign in again unless their account is locked.', form: form() } },
|
|
34
|
+
},
|
|
35
|
+
roles: {
|
|
36
|
+
body: `<section class="ui-card">{{table}}<p class="ui-muted">{{note}}</p></section>{{#if assign}}<section class="ui-card"><h2>{{assign.heading}}</h2>{{assign.form}}</section>{{/if}}`,
|
|
37
|
+
sample: { intro: 'Review the permissions each role grants. Assign reviewed roles to an account below.', note: 'Role definitions are read-only here.', table: m('<div class="ui-table-wrap"></div>'), assign: { heading: 'Assign roles', form: form() } },
|
|
38
|
+
},
|
|
39
|
+
audit: {
|
|
40
|
+
body: `<section class="ui-card ui-filter-card">{{filters}}</section><section class="ui-card">{{#if table}}{{table}}{{else}}<p class="ui-empty">{{empty}}</p>{{/if}}${next}</section>{{#if export}}<details class="ui-card"><summary>{{export.summary}}</summary>{{export.form}}</details>{{/if}}`,
|
|
41
|
+
sample: { filtersHeading: 'Filter audit events', intro: 'Review recorded security events. Use filters to narrow the time range or account.', filters: m('<form method="get"></form>'), table: m('<div class="ui-table-wrap"></div>'), empty: 'No audit events match these filters.', nextHref: null, nextLabel: 'Next', export: { summary: m('<span>Export audit events</span>'), form: m('<form method="get"></form>') } },
|
|
42
|
+
},
|
|
43
|
+
health: {
|
|
44
|
+
body: `{{#if unavailable}}${empty('unavailable')}{{else}}<section class="ui-card"><dl class="ui-definition-grid">{{#each facts}}<dt>{{term}}</dt><dd>{{value}}</dd>{{/each}}</dl></section><section class="ui-card">{{components}}</section><section class="ui-card"><h2>{{alertsHeading}}</h2>{{#if alerts}}<ul class="ui-list">{{#each alerts}}<li>{{this}}</li>{{/each}}</ul>{{else}}<p class="ui-empty">{{noAlerts}}</p>{{/if}}</section>{{/if}}`,
|
|
45
|
+
sample: { unavailable: null, intro: 'Reported observations from this host and its configured integrations.', facts: [{ term: 'Runtime version', value: m('<code>1.0.0</code>') }], components: m('<div class="ui-table-wrap"></div>'), alertsHeading: 'Alerts', alerts: ['Notification sender reported a failure.'], noAlerts: 'No alerts reported.' },
|
|
46
|
+
},
|
|
47
|
+
cases: {
|
|
48
|
+
body: `{{#if cases}}<ul class="ui-list">{{#each cases}}<li class="ui-card"><div class="ui-section-heading"><h2><code>{{action}}</code></h2><span class="ui-badge">{{status}}</span></div><dl class="ui-definition-grid"><dt>{{accountLabel}}</dt><dd>{{accountId}}</dd><dt>{{reasonLabel}}</dt><dd>{{reason}}</dd></dl>{{#if notes}}<ul class="ui-list">{{#each notes}}<li><p>{{note}}</p><small class="ui-muted">{{actorId}}</small></li>{{/each}}</ul>{{/if}}{{actions}}</li>{{/each}}</ul>{{else}}${empty('empty')}{{/if}}{{#if create}}<details class="ui-card"><summary>{{create.summary}}</summary>{{create.form}}</details>{{/if}}${next}`,
|
|
49
|
+
sample: { accountLabel: 'Account ID', reasonLabel: 'Reason', cases: [{ action: 'unlock', status: 'pending', accountId: 'account', reason: 'Restore access with review', notes: [{ note: 'Checked with the account holder.', actorId: 'actor' }], actions: m('<details></details>') }], empty: 'No support cases to review.', create: { summary: 'Create a support case', form: form() }, nextHref: null, nextLabel: 'Next' },
|
|
50
|
+
},
|
|
51
|
+
registrations: {
|
|
52
|
+
body: `{{#if requests}}<ul class="ui-list">{{#each requests}}<li class="ui-card"><h2>{{email}}</h2>{{form}}</li>{{/each}}</ul>{{else}}${empty('empty')}{{/if}}${next}{{#if invite}}<section class="ui-card"><h2>{{invite.heading}}</h2>{{invite.form}}</section>{{/if}}`,
|
|
53
|
+
sample: { requests: [{ email: 'a***@example.test', form: form() }], empty: 'No registration requests are waiting for approval.', nextHref: null, nextLabel: 'Next', invite: { heading: 'Send invitation', form: form() } },
|
|
54
|
+
},
|
|
55
|
+
'recovery-cases': {
|
|
56
|
+
body: `<p class="ui-muted">{{intro}}</p>{{#if cases}}<ul class="ui-list">{{#each cases}}<li class="ui-card"><h2><code>{{id}}</code></h2><dl class="ui-definition-grid">{{#each facts}}<dt>{{term}}</dt><dd>{{value}}</dd>{{/each}}</dl><p>{{reason}}</p><ul>{{#each notes}}<li>{{actor}}: {{note}}</li>{{/each}}</ul>{{forms}}</li>{{/each}}</ul>{{else}}${empty('empty')}{{/if}}{{#if create}}<details class="ui-card"><summary>{{create.summary}}</summary>{{create.form}}</details>{{/if}}{{#if nextHref}}<a href="{{href nextHref}}">{{nextLabel}}</a>{{/if}}`,
|
|
57
|
+
sample: { intro: 'Manual recovery requires independent assessment of the evidence.', cases: [{ id: 'case', facts: [{ term: 'Account', value: 'account' }], reason: 'Lost every factor', notes: [{ actor: 'actor', note: 'Reviewed the evidence.' }], forms: form() }], empty: 'No manual recovery cases to review.', create: { summary: 'Create a recovery case', form: form() }, nextHref: null, nextLabel: 'Next' },
|
|
58
|
+
},
|
|
59
|
+
'account-operations': {
|
|
60
|
+
body: `{{#if bulk}}<section class="ui-card"><p>{{bulk.info}}</p>{{bulk.form}}</section>{{/if}}{{#if account}}<section class="ui-card"><p class="ui-muted">{{account.idLabel}}: <code>{{account.accountId}}</code></p><p>{{account.info}}</p><dl class="ui-definition-grid">{{#each account.facts}}<dt>{{term}}</dt><dd>{{value}}</dd>{{/each}}</dl></section><ul class="ui-list">{{account.methods}}</ul>{{#if account.empty}}<p class="ui-empty">{{account.empty}}</p>{{/if}}<p><a class="ui-button-secondary" href="{{href account.caseHref}}">{{account.caseLabel}}</a></p>{{account.actions}}{{/if}}`,
|
|
61
|
+
sample: { bulk: null, account: { idLabel: 'Account ID', accountId: 'account', info: 'Review sign-in methods before changing account access.', facts: [{ term: 'Password', value: 'Yes' }], methods: m('<li class="ui-card"></li>'), empty: null, caseHref: '/admin/cases', caseLabel: 'Open a two-administrator case', actions: m('<details></details>') } },
|
|
62
|
+
},
|
|
63
|
+
reveal: {
|
|
64
|
+
body: `<dl><dt>{{idLabel}}</dt><dd>{{id}}</dd><dt>{{emailLabel}}</dt><dd>{{email}}</dd></dl>`,
|
|
65
|
+
sample: { idLabel: 'Account ID', id: 'account', emailLabel: 'Email', email: 'ada@example.test' },
|
|
66
|
+
},
|
|
67
|
+
status: {
|
|
68
|
+
body: `<section class="ui-card"><p role="{{#if alert}}alert{{else}}status{{/if}}"{{#if alert}} class="error"{{/if}}>{{message}}</p>{{#if href}}<a class="ui-button-secondary" href="{{href href}}">{{label}}</a>{{/if}}</section>`,
|
|
69
|
+
sample: { alert: false, message: 'Operation completed.', href: '/admin', label: 'Return to overview' },
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
/** Template sources with their view model samples, keyed by full template name. */
|
|
73
|
+
export const adminTemplates = Object.freeze(Object.fromEntries(Object.entries(screens).map(([name, screen]) => [`admin/${name}`, Object.freeze({ source: declare(name, screen.body), sample: screen.sample })])));
|
|
74
|
+
export const adminTemplateNames = Object.freeze(Object.keys(adminTemplates));
|
|
75
|
+
/** What the host hands to `createUiExtension({ extensions: [adminUiTemplates] })`. */
|
|
76
|
+
export const adminUiTemplates = Object.freeze({ name: 'admin', templates: Object.freeze(Object.fromEntries(Object.entries(adminTemplates).map(([name, template]) => [name, template.source]))) });
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders one admin screen either through the urlcode-ui kit (when the host
|
|
3
|
+
* supplied the `ui` extension and the runtime activated it) or through the
|
|
4
|
+
* shared primitives inside the console shell. Both paths render the same
|
|
5
|
+
* `admin/*` template with the same view; only the layout around it differs.
|
|
6
|
+
* Flows, permissions, freshness gates, escaping, CSRF and headers stay in the
|
|
7
|
+
* extension code that computes the view.
|
|
8
|
+
*/
|
|
9
|
+
import { Markup } from '@jimhoyd/urlcode-ui';
|
|
10
|
+
import type { Kit, LocalePreferences, Presentation, PresentationContext, ViewModel } from '@jimhoyd/urlcode-ui';
|
|
11
|
+
import type { AuthHttpResponse } from '@jimhoyd/urlcode-auth';
|
|
12
|
+
import type { ExtensionRequest } from '@jimhoyd/urlcode/extensions';
|
|
13
|
+
/** The object `createUiExtension` returns, structurally: the kit once the runtime has activated the `ui` extension. */
|
|
14
|
+
export interface UiHost {
|
|
15
|
+
readonly kit: Kit;
|
|
16
|
+
readonly active: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** One admin screen: an `admin/*` template name and the view the extension computed for it. */
|
|
19
|
+
export interface Screen {
|
|
20
|
+
name: string;
|
|
21
|
+
view: ViewModel;
|
|
22
|
+
}
|
|
23
|
+
export type RenderPath = 'primitives' | 'kit';
|
|
24
|
+
export interface ScreenOptions {
|
|
25
|
+
status?: number | undefined;
|
|
26
|
+
headers?: [string, string][] | undefined;
|
|
27
|
+
/** The console copy the extension resolved for this request; titles and the primitive layout use it. */
|
|
28
|
+
presentation: PresentationContext;
|
|
29
|
+
/** The locale preferences the extension resolved, so the kit layout follows the same language. */
|
|
30
|
+
preferences?: LocalePreferences | undefined;
|
|
31
|
+
/** Sidebar markup on the primitive path; the kit path receives the same links as `nav` items. */
|
|
32
|
+
shell?: {
|
|
33
|
+
sidebar: string;
|
|
34
|
+
nav: {
|
|
35
|
+
href: string;
|
|
36
|
+
label: string;
|
|
37
|
+
current: boolean;
|
|
38
|
+
}[];
|
|
39
|
+
menu: {
|
|
40
|
+
label: string;
|
|
41
|
+
items: {
|
|
42
|
+
href: string;
|
|
43
|
+
label: string;
|
|
44
|
+
}[];
|
|
45
|
+
};
|
|
46
|
+
} | undefined;
|
|
47
|
+
ui?: UiHost | undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Test hook: sees every screen before it renders, with the path that renders it. */
|
|
50
|
+
export declare const screenObserver: {
|
|
51
|
+
current?: ((screen: Screen, path: RenderPath) => void) | undefined;
|
|
52
|
+
};
|
|
53
|
+
/** The kit a request renders through, or nothing when the host gave no `ui` or the runtime has not activated it. */
|
|
54
|
+
export declare const activeKit: (ui: UiHost | undefined) => Kit | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* The copy source: the host's presentation; else the kit's, composed with the admin catalogue, when the host registered
|
|
57
|
+
* the auth catalogue with the ui extension (the kit's own catalogue limit leaves no room for the admin catalogue too);
|
|
58
|
+
* else the bundled English.
|
|
59
|
+
*/
|
|
60
|
+
export declare function presentationSource(presentation: Presentation | undefined, ui: UiHost | undefined, fallback: Presentation): Presentation;
|
|
61
|
+
/** Renders a screen: through `ui.kit` when the host supplied the ui extension and it is active, otherwise through the shared primitives. */
|
|
62
|
+
export declare function screenResponse(title: string, screen: Screen, options: ScreenOptions): AuthHttpResponse;
|
|
63
|
+
/** The failure page: JSON for API clients, otherwise the `admin/status` screen with the same status and message auth's `httpFailure` derives. */
|
|
64
|
+
export declare function failureResponse(error: unknown, request: ExtensionRequest, options: ScreenOptions): AuthHttpResponse;
|
|
65
|
+
export declare const markup: (html: string) => Markup;
|