@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/dist/admin-ui.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
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 { compileTemplate, Markup } from '@jimhoyd/urlcode-ui';
|
|
10
|
+
import { AuthHttpError, httpFailure, wantsJson } from '@jimhoyd/urlcode-auth';
|
|
11
|
+
import { adminPage } from "./admin-presentation.js";
|
|
12
|
+
import { adminTemplates } from "./admin-templates.js";
|
|
13
|
+
import { createAdminPresentation } from "./admin-copy.js";
|
|
14
|
+
/** Test hook: sees every screen before it renders, with the path that renders it. */
|
|
15
|
+
export const screenObserver = {};
|
|
16
|
+
const localTemplates = new Map();
|
|
17
|
+
function localTemplate(name) {
|
|
18
|
+
let template = localTemplates.get(name);
|
|
19
|
+
if (!template && Object.hasOwn(adminTemplates, name)) {
|
|
20
|
+
template = compileTemplate(name, adminTemplates[name].source);
|
|
21
|
+
localTemplates.set(name, template);
|
|
22
|
+
}
|
|
23
|
+
return template;
|
|
24
|
+
}
|
|
25
|
+
/** The kit a request renders through, or nothing when the host gave no `ui` or the runtime has not activated it. */
|
|
26
|
+
export const activeKit = (ui) => ui?.active ? ui.kit : undefined;
|
|
27
|
+
const composed = new WeakMap();
|
|
28
|
+
/**
|
|
29
|
+
* The copy source: the host's presentation; else the kit's, composed with the admin catalogue, when the host registered
|
|
30
|
+
* the auth catalogue with the ui extension (the kit's own catalogue limit leaves no room for the admin catalogue too);
|
|
31
|
+
* else the bundled English.
|
|
32
|
+
*/
|
|
33
|
+
export function presentationSource(presentation, ui, fallback) {
|
|
34
|
+
if (presentation)
|
|
35
|
+
return presentation;
|
|
36
|
+
const kit = activeKit(ui);
|
|
37
|
+
if (!kit || !Object.hasOwn(kit.presentation.english, 'page.admin'))
|
|
38
|
+
return fallback;
|
|
39
|
+
let source = composed.get(kit.presentation);
|
|
40
|
+
if (!source) {
|
|
41
|
+
source = createAdminPresentation({ base: kit.presentation });
|
|
42
|
+
composed.set(kit.presentation, source);
|
|
43
|
+
}
|
|
44
|
+
return source;
|
|
45
|
+
}
|
|
46
|
+
/** Renders a screen: through `ui.kit` when the host supplied the ui extension and it is active, otherwise through the shared primitives. */
|
|
47
|
+
export function screenResponse(title, screen, options) {
|
|
48
|
+
if (!Object.hasOwn(adminTemplates, screen.name))
|
|
49
|
+
throw new Error(`Unknown admin screen: ${screen.name.slice(0, 64)}`);
|
|
50
|
+
const kit = activeKit(options.ui);
|
|
51
|
+
screenObserver.current?.(screen, kit ? 'kit' : 'primitives');
|
|
52
|
+
if (!kit) {
|
|
53
|
+
const markup = localTemplate(screen.name).render(screen.view, options.presentation, localTemplate).html;
|
|
54
|
+
return adminPage(title, (options.shell?.sidebar ?? '') + markup, options.status ?? 200, options.headers ?? [], undefined, options.presentation);
|
|
55
|
+
}
|
|
56
|
+
const context = kit.resolveContext(options.preferences);
|
|
57
|
+
const rendered = kit.render(screen.name, screen.view, options.presentation);
|
|
58
|
+
// The kit owns the console shell on this path: it builds the sidebar, the page header and the skip target from `nav`, `menu` and the title.
|
|
59
|
+
const page = kit.wrap(rendered, { title: options.presentation.textSource(title), context, layout: options.shell ? 'application' : 'default', ...(options.status !== undefined ? { status: options.status } : {}), ...(options.headers ? { headers: options.headers } : {}), ...(options.shell ? { nav: options.shell.nav, menu: options.shell.menu } : {}) });
|
|
60
|
+
return page;
|
|
61
|
+
}
|
|
62
|
+
/** The failure page: JSON for API clients, otherwise the `admin/status` screen with the same status and message auth's `httpFailure` derives. */
|
|
63
|
+
export function failureResponse(error, request, options) {
|
|
64
|
+
if (wantsJson(request) || !activeKit(options.ui))
|
|
65
|
+
return httpFailure(error, request, options.presentation);
|
|
66
|
+
const known = error instanceof AuthHttpError || (error instanceof Error && 'status' in error && typeof error.status === 'number' && error.status >= 400 && error.status < 500);
|
|
67
|
+
const status = known ? error.status : 500;
|
|
68
|
+
const source = error instanceof AuthHttpError ? error.message : status >= 500 ? 'Service unavailable' : 'Request could not be completed';
|
|
69
|
+
return screenResponse('Request could not be completed', { name: 'admin/status', view: { alert: true, message: options.presentation.textSource(source), href: null, label: null } }, { ...options, status });
|
|
70
|
+
}
|
|
71
|
+
export const markup = (html) => new Markup(html);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { AuthService } from '@jimhoyd/urlcode-auth';
|
|
2
|
+
/** Buffer the complete bounded selection; failures never return partial CSV. */
|
|
3
|
+
export declare function exportUserRange(service: AuthService, actorToken: string, query: URLSearchParams, reason: string): Promise<Uint8Array>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AuthHttpError, hasPermission } from '@jimhoyd/urlcode-auth';
|
|
2
|
+
import { userFilters, usersCsv } from "./admin-reporting.js";
|
|
3
|
+
/** Buffer the complete bounded selection; failures never return partial CSV. */
|
|
4
|
+
export async function exportUserRange(service, actorToken, query, reason) {
|
|
5
|
+
if (query.has('after'))
|
|
6
|
+
throw new AuthHttpError(400, 'Complete exports start at the beginning of the selection');
|
|
7
|
+
if (typeof reason !== 'string' || !reason.trim() || reason.length > 1000)
|
|
8
|
+
throw new AuthHttpError(400, 'A bounded export reason is required');
|
|
9
|
+
const filters = userFilters(query), deadline = performance.now() + 5000;
|
|
10
|
+
const bounded = async (operation) => {
|
|
11
|
+
const remaining = deadline - performance.now();
|
|
12
|
+
if (remaining <= 0)
|
|
13
|
+
throw new AuthHttpError(413, 'Narrow the user filters');
|
|
14
|
+
let timer;
|
|
15
|
+
try {
|
|
16
|
+
return await Promise.race([operation(), new Promise((_, reject) => { timer = setTimeout(() => reject(new AuthHttpError(413, 'Narrow the user filters')), remaining); })]);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const authorize = async () => {
|
|
23
|
+
const actor = await bounded(() => service.authenticate(actorToken));
|
|
24
|
+
if (!actor || actor.impersonatorId || !hasPermission(actor, 'auth.users.read') || !hasPermission(actor, 'auth.users.export') || actor.authenticatedAt <= 0 || Date.now() - actor.authenticatedAt > 300000)
|
|
25
|
+
throw new AuthHttpError(403, 'Fresh user export authority required');
|
|
26
|
+
};
|
|
27
|
+
const chunks = [usersCsv([])], seen = new Set(), cursors = new Set();
|
|
28
|
+
let bytes = chunks[0].byteLength, after;
|
|
29
|
+
do {
|
|
30
|
+
await authorize();
|
|
31
|
+
const page = await bounded(() => service.listUsers({ ...filters, limit: 100, ...(after ? { after } : {}) }));
|
|
32
|
+
if (seen.size + page.users.length > 5000)
|
|
33
|
+
throw new AuthHttpError(413, 'Narrow the user filters');
|
|
34
|
+
for (const user of page.users) {
|
|
35
|
+
if (seen.has(user.id))
|
|
36
|
+
throw new AuthHttpError(409, 'Users changed during export; restart the selection');
|
|
37
|
+
seen.add(user.id);
|
|
38
|
+
const exported = await bounded(() => service.adminExport({ actorToken, accountId: user.id, reason }));
|
|
39
|
+
const full = usersCsv([{ ...exported.user, ...('observedLastSeen' in user ? { observedLastSeen: user.observedLastSeen } : {}) }]);
|
|
40
|
+
const row = full.slice(chunks[0].byteLength);
|
|
41
|
+
bytes += row.byteLength;
|
|
42
|
+
if (bytes > 4 * 1024 * 1024)
|
|
43
|
+
throw new AuthHttpError(413, 'Narrow the user filters');
|
|
44
|
+
chunks.push(row);
|
|
45
|
+
}
|
|
46
|
+
after = page.next;
|
|
47
|
+
if (after) {
|
|
48
|
+
if (cursors.has(after))
|
|
49
|
+
throw new AuthHttpError(409, 'Users changed during export; restart the selection');
|
|
50
|
+
cursors.add(after);
|
|
51
|
+
}
|
|
52
|
+
} while (after);
|
|
53
|
+
await authorize();
|
|
54
|
+
if (performance.now() > deadline)
|
|
55
|
+
throw new AuthHttpError(413, 'Narrow the user filters');
|
|
56
|
+
return Buffer.concat(chunks, bytes);
|
|
57
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AuthPrincipal, AuthUser, PresentationContext } from '@jimhoyd/urlcode-auth';
|
|
2
|
+
import type { Screen } from './admin-ui.ts';
|
|
3
|
+
/** Account workflows and authority stay here; URLCode UI supplies only generic visual primitives. */
|
|
4
|
+
export declare function userDirectory(input: {
|
|
5
|
+
users: AuthUser[];
|
|
6
|
+
next?: string;
|
|
7
|
+
query: URLSearchParams;
|
|
8
|
+
principal: AuthPrincipal;
|
|
9
|
+
mount: string;
|
|
10
|
+
csrf: string;
|
|
11
|
+
presentation: PresentationContext;
|
|
12
|
+
canSendSetup: boolean;
|
|
13
|
+
}): Screen;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { escapeHtml, icon, hiddenField, postForm } from '@jimhoyd/urlcode-ui';
|
|
2
|
+
import { csrfField, formField, hasPermission } from '@jimhoyd/urlcode-auth';
|
|
3
|
+
import { nextPage, observedLastSeen, userFilterFields, userFilterKeys } from "./admin-reporting.js";
|
|
4
|
+
import { markup } from "./admin-ui.js";
|
|
5
|
+
/** Account workflows and authority stay here; URLCode UI supplies only generic visual primitives. */
|
|
6
|
+
export function userDirectory(input) {
|
|
7
|
+
const { users, query, principal, mount, csrf, presentation } = input;
|
|
8
|
+
const text = (source) => presentation.textSource(source), html = (source) => escapeHtml(text(source));
|
|
9
|
+
const tr = (key, values) => presentation.text(key, values), trh = (key, values) => escapeHtml(tr(key, values));
|
|
10
|
+
const field = (name, label, type = 'text') => formField(name, text(label), type);
|
|
11
|
+
const form = (path, fields, label, destructive = false) => postForm({ action: mount + path, csrf, fields, label: text(label), destructive, className: 'ui-form-grid' });
|
|
12
|
+
const manage = hasPermission(principal, 'auth.users.manage'), sessions = hasPermission(principal, 'auth.sessions.manage'), select = manage || sessions;
|
|
13
|
+
const setup = input.canSendSetup && hasPermission(principal, 'auth.users.create') ? { summary: text('Create account'), form: markup(form('/users/create', field('email', 'Email address', 'email') + field('reason', 'Reason'), 'Send setup link')) } : null;
|
|
14
|
+
const filters = `<form method="get" action="${escapeHtml(mount + '/users')}">${userFilterFields(query, text)}<div class="ui-toolbar"><button type="submit">${icon('search')}<span>${trh('users.filter.apply')}</span></button><a href="${escapeHtml(mount + '/users' + (query.get('lang') ? '?lang=' + encodeURIComponent(query.get('lang')) : ''))}">${html('Reset filters')}</a></div></form>`;
|
|
15
|
+
const exports = hasPermission(principal, 'auth.users.export') ? { summary: markup(icon('download') + `<span>${html('Export accounts')}</span>`), forms: markup(form('/users/export-page', [...userFilterKeys, 'after'].map(key => hiddenField(key, query.get(key) || '')).join('') + field('reason', 'Reason') + `<p class="ui-muted">${trh('copy.exportThisFilteredPageOnlyAtMost50AccountsEmailAddressesStayMasked')}</p>`, 'Export page (CSV)') + form('/users/export-range', userFilterKeys.map(key => hiddenField(key, query.get(key) || '')).join('') + field('reason', 'Reason') + `<p class="ui-muted">${html('Complete export: at most 5,000 accounts, 4 MiB and 5 seconds. Narrow filters if the limit is exceeded.')}</p>`, 'Export all (CSV)')) } : null;
|
|
16
|
+
const bulk = select ? { summary: tr('copy.bulkAction'), form: markup(`<form class="ui-form-grid" id="bulk-users" method="post" action="${escapeHtml(mount + '/users/bulk')}">${csrfField(csrf)}<label>${trh('copy.bulkAction')}<select name="action">${manage ? `<option value="lock">${trh('copy.lock')}</option><option value="unlock">${trh('copy.unlock')}</option>` : ''}${sessions ? `<option value="revoke-sessions">${trh('action.revokeSessions')}</option>` : ''}</select></label>${field('reason', 'Reason')}${field('confirmation', 'Type LOCK, UNLOCK or REVOKE-SESSIONS followed by a space and the selected count')}<p class="ui-muted">${trh('copy.selectAccountsInTheTableAllSelectedAccountsMustBePermittedOtherwiseNoneAreChanged')}</p><button class="ui-button-destructive" type="submit">${trh('copy.applyBulkAction')}</button></form>`) } : null;
|
|
17
|
+
const rows = users.map(user => `<tr><td>${user.id !== principal.id && user.status !== 'pending-delete' && select ? `<input type="checkbox" form="bulk-users" name="selected.${escapeHtml(user.id)}" value="yes" aria-label="${trh('action.selectAccount', { account: user.email })}">` : ''}</td><td><a href="${escapeHtml(mount + '/users/detail?id=' + encodeURIComponent(user.id))}"><strong>${escapeHtml(user.email)}</strong></a><small class="ui-muted ui-account-id">${escapeHtml(user.id)}</small></td><td><span class="ui-badge" data-status="${escapeHtml(user.status)}">${html(user.status)}</span></td><td class="ui-muted">${observedLastSeen(user) ? `<time datetime="${escapeHtml(observedLastSeen(user))}">${escapeHtml(observedLastSeen(user).replace('T', ' ').slice(0, 16) + ' UTC')}</time>` : '—'}</td><td>${user.roles.map(role => `<span class="ui-badge">${escapeHtml(role)}</span>`).join(' ')}</td><td>${manage && user.id !== principal.id && user.status !== 'pending-delete' ? `<details><summary>${html(user.status === 'active' ? 'Lock account' : 'Unlock account')}</summary>${form('/users/status', hiddenField('accountId', user.id) + hiddenField('status', user.status === 'active' ? 'locked' : 'active') + field('reason', 'Reason'), user.status === 'active' ? 'Lock account' : 'Unlock account', user.status === 'active')}</details>` : ''}</td></tr>`).join('');
|
|
18
|
+
return { name: 'admin/users', view: { setup, filters: markup(filters), heading: tr('nav.users'), count: tr('number.value', { value: users.length }), caption: tr('copy.accountsEmailAddressesMasked'), columns: { select: tr('copy.select'), account: tr('nav.account'), status: tr('copy.status'), lastSeen: tr('users.filter.lastSeen'), roles: tr('nav.roles'), actions: tr('copy.actions') }, rows: markup(rows), empty: text('No matching accounts. Try changing your filters.'), caveat: tr('users.filter.activityCaveat'), nextHref: input.next ? nextPage(mount + '/users', query, input.next, userFilterKeys) : null, nextLabel: tr('action.next'), bulk, exports } };
|
|
19
|
+
}
|
package/dist/admin.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { UiHost } from './admin-ui.ts';
|
|
2
|
+
import type { AdminAccountDelivery } from '@jimhoyd/urlcode-auth';
|
|
3
|
+
import type { ManualRecoveryDelivery } from '@jimhoyd/urlcode-auth';
|
|
4
|
+
import type { AdminHealthProvider } from './admin-health.ts';
|
|
5
|
+
import type { RuntimeExtension } from '@jimhoyd/urlcode/extensions';
|
|
6
|
+
import type { AuthService, Presentation } from '@jimhoyd/urlcode-auth';
|
|
7
|
+
export interface AdminExtensionOptions {
|
|
8
|
+
sendAccountAdministration?: (message: AdminAccountDelivery & {
|
|
9
|
+
signal: AbortSignal;
|
|
10
|
+
}) => Promise<void>;
|
|
11
|
+
sendRecovery?: (message: ManualRecoveryDelivery) => Promise<void>;
|
|
12
|
+
presentation?: Presentation;
|
|
13
|
+
/** The `ui` extension from `createUiExtension`, declared before admin in the host file. Screens then render through its kit. */
|
|
14
|
+
ui?: UiHost;
|
|
15
|
+
health?: AdminHealthProvider;
|
|
16
|
+
service: AuthService;
|
|
17
|
+
csrfKey: Uint8Array;
|
|
18
|
+
projectSha256: string;
|
|
19
|
+
authMount?: string;
|
|
20
|
+
notifyImpersonation?: (message: {
|
|
21
|
+
email: string;
|
|
22
|
+
actorId: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
signal: AbortSignal;
|
|
25
|
+
}) => Promise<void>;
|
|
26
|
+
sendSetup?: (message: {
|
|
27
|
+
email: string;
|
|
28
|
+
token: string;
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
}) => Promise<void>;
|
|
31
|
+
sendInvitation?: (message: {
|
|
32
|
+
email: string;
|
|
33
|
+
token: string;
|
|
34
|
+
signal: AbortSignal;
|
|
35
|
+
}) => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export declare function adminExtension(options: AdminExtensionOptions): RuntimeExtension;
|
package/dist/admin.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { rolesScreen, sessionsScreen, auditScreen, healthScreen, casesScreen, registrationsScreen } from "./admin-screens.js";
|
|
2
|
+
import { createAdminPresentation } from "./admin-copy.js";
|
|
3
|
+
import { userDirectory } from "./admin-users.js";
|
|
4
|
+
import { escapeHtml, icon, postForm, withDeadline } from '@jimhoyd/urlcode-ui';
|
|
5
|
+
import { failureResponse, markup, presentationSource, screenResponse } from "./admin-ui.js";
|
|
6
|
+
import { dashboardSummary } from "./admin-dashboard.js";
|
|
7
|
+
import { accountDetail } from "./admin-detail.js";
|
|
8
|
+
import { exportUserRange } from "./admin-user-export.js";
|
|
9
|
+
import { createAdminAccount } from "./admin-account.js";
|
|
10
|
+
import { createAdminRecovery } from "./admin-recovery.js";
|
|
11
|
+
import { exportAuditRange } from "./admin-audit-export.js";
|
|
12
|
+
import { createHealthReader } from "./admin-health.js";
|
|
13
|
+
import { maskEmail, sessionFilters, userFilters, userFilterKeys, auditFilters, selectedNames, selectedAccounts, usersCsv } from "./admin-reporting.js";
|
|
14
|
+
import { AuthHttp, AuthHttpError, formField as baseField, jsonResponse, readFields, wantsJson, hasPermission } from '@jimhoyd/urlcode-auth';
|
|
15
|
+
const defaultPresentation = createAdminPresentation();
|
|
16
|
+
const schema = { type: 'object', additionalProperties: false, properties: {} };
|
|
17
|
+
const permissions = ['auth.users.reveal', 'auth.audit.export', 'auth.health.read', 'auth.cases.read', 'auth.cases.manage', 'auth.users.impersonate', 'auth.users.export', 'auth.users.create', 'auth.users.read', 'auth.users.manage', 'auth.audit.read', 'auth.sessions.manage', 'auth.roles.read'];
|
|
18
|
+
export function adminExtension(options) {
|
|
19
|
+
const authMount = options.authMount || '/account';
|
|
20
|
+
if (!/^\/[A-Za-z0-9/_-]*$/.test(authMount) || authMount.includes('//'))
|
|
21
|
+
throw new Error('Invalid auth mount');
|
|
22
|
+
return { name: 'admin', version: '1', projectSha256: options.projectSha256, targets: ['node'], schema, credentialHeaders: ['cookie', 'authorization', 'x-csrf-token'],
|
|
23
|
+
activate(_config, context) {
|
|
24
|
+
if (context.mounts.length !== 1)
|
|
25
|
+
throw new Error('Admin requires exactly one mount');
|
|
26
|
+
const readHealth = options.health ? createHealthReader(options.health) : undefined;
|
|
27
|
+
const mount = context.mounts[0], http = new AuthHttp({ origin: context.origin, csrfKey: options.csrfKey }), service = options.service;
|
|
28
|
+
const accounts = createAdminAccount({ service, ...(options.sendAccountAdministration ? { sendAccountAdministration: options.sendAccountAdministration } : {}) }, http, mount);
|
|
29
|
+
const recovery = createAdminRecovery({ service, ...(options.sendRecovery ? { sendRecovery: options.sendRecovery } : {}) }, http, mount);
|
|
30
|
+
function requirePermission(principal, permission) {
|
|
31
|
+
if (!hasPermission(principal, permission))
|
|
32
|
+
throw new AuthHttpError(403, 'Permission required');
|
|
33
|
+
}
|
|
34
|
+
function navigation(principal, text, tr, current) {
|
|
35
|
+
const isCurrent = (path) => path === '/' ? (current === '/' || current === '/dashboard') : current === path || current.startsWith(path + '/');
|
|
36
|
+
const items = [{ href: mount + '/', label: tr('nav.overview'), current: isCurrent('/'), symbol: 'home' }, ...[['users', 'Users', 'auth.users.read', 'users'], ['sessions', 'Sessions', 'auth.sessions.manage', 'monitor'], ['registrations', 'Registration', 'auth.users.manage', 'mail'], ['roles', 'Roles', 'auth.roles.read', 'shield'], ['audit', 'Audit', 'auth.audit.read', 'list'], ['cases', 'Cases', 'auth.cases.read', 'circle-alert'], ['health', 'Service health', 'auth.health.read', 'activity']].filter(([_path, _label, permission]) => hasPermission(principal, permission)).map(([path, label, , symbol]) => ({ href: mount + '/' + path, label: text(label), current: isCurrent('/' + path), symbol })), ...(accounts.enabled() && hasPermission(principal, 'auth.users.manage') ? [{ href: mount + '/account-operations', label: tr('adminOps.title'), current: isCurrent('/account-operations'), symbol: 'settings' }] : []), ...(recovery.enabled() && hasPermission(principal, 'auth.cases.read') ? [{ href: mount + '/recovery-cases', label: tr('manualRecovery.title'), current: isCurrent('/recovery-cases'), symbol: 'key' }] : [])];
|
|
37
|
+
const link = (item) => `<a class="ui-nav-link" href="${escapeHtml(item.href)}"${item.current ? ' aria-current="page"' : ''}>${icon(item.symbol)}<span>${escapeHtml(item.label)}</span></a>`;
|
|
38
|
+
const menu = { label: tr('nav.account'), items: [{ href: authMount + '/account', label: tr('nav.account') }, { href: authMount + '/step-up', label: tr('action.confirm') }] };
|
|
39
|
+
const contents = `<nav aria-label="${escapeHtml(tr('page.admin'))}">${items.map(link).join('')}</nav><div class="ui-sidebar-footer"><a class="ui-nav-link" href="${escapeHtml(authMount + '/account')}">${icon('user')}<span>${escapeHtml(tr('nav.account'))}</span></a><a class="ui-nav-link" href="${escapeHtml(authMount + '/step-up')}">${icon('lock')}<span>${escapeHtml(tr('action.confirm'))}</span></a></div>`;
|
|
40
|
+
return { sidebar: `<aside class="ui-sidebar"><a class="ui-brand" href="${escapeHtml(mount)}"><span aria-hidden="true">U</span><strong>URLCode</strong></a><div class="ui-desktop-navigation"><p class="ui-muted">${escapeHtml(tr('page.admin'))}</p>${contents}</div><details class="ui-mobile-navigation"><summary>${icon('list')}<span>${escapeHtml(tr('page.admin'))}</span></summary>${contents}</details></aside>`, nav: items.map(({ href, label, current }) => ({ href, label, current })), menu };
|
|
41
|
+
}
|
|
42
|
+
return { async handle(request) {
|
|
43
|
+
// The runtime activates `ui` before admin, but its kit is read per request, never captured at activation.
|
|
44
|
+
const source = presentationSource(options.presentation, options.ui, defaultPresentation);
|
|
45
|
+
let preferences = { ...(request.query.get('lang') ? { queryLocale: request.query.get('lang') } : {}), ...(request.headers.get('accept-language') ? { acceptLanguage: request.headers.get('accept-language') } : {}) };
|
|
46
|
+
let presentation = source.resolve(preferences);
|
|
47
|
+
const render = () => ({ presentation, preferences, ui: options.ui });
|
|
48
|
+
const tr = (key, values) => presentation.text(key, values);
|
|
49
|
+
const formField = (name, label, type = 'text', autocomplete = 'off', required = true) => baseField(name, presentation.textSource(label), type, autocomplete, required);
|
|
50
|
+
const form = (action, csrf, fields, button) => postForm({ action, csrf, fields, label: presentation.textSource(button), className: 'ui-form-grid' });
|
|
51
|
+
try {
|
|
52
|
+
const token = http.session(request), principal = token ? await service.authenticate(token) : null;
|
|
53
|
+
if (!token || !principal || principal.impersonatorId || !permissions.some(permission => hasPermission(principal, permission)))
|
|
54
|
+
throw new AuthHttpError(404, 'Not found');
|
|
55
|
+
const accountLocale = (await service.getUser(principal.id))?.profile?.locale;
|
|
56
|
+
if (accountLocale) {
|
|
57
|
+
preferences = { accountLocale, ...preferences };
|
|
58
|
+
presentation = source.resolve(preferences);
|
|
59
|
+
}
|
|
60
|
+
const path = request.path.slice(mount.length) || '/';
|
|
61
|
+
if (!['GET', 'HEAD', 'POST'].includes(request.method))
|
|
62
|
+
return jsonResponse(405, { error: 'Method not allowed' }, [['allow', 'GET, HEAD, POST']]);
|
|
63
|
+
const csrf = http.token(token), shell = navigation(principal, value => presentation.textSource(value), tr, path);
|
|
64
|
+
const screen = (title, name, view, status, headers) => screenResponse(title, { name: 'admin/' + name, view }, { ...render(), shell, status, headers });
|
|
65
|
+
const status = (title, message, href = null, label = null) => screen(title, 'status', { alert: false, message, href, label });
|
|
66
|
+
const accountResult = await accounts.handle(request, principal, token, { ...render(), shell });
|
|
67
|
+
if (accountResult)
|
|
68
|
+
return accountResult;
|
|
69
|
+
const recoveryResult = await recovery.handle(request, principal, token, { ...render(), shell });
|
|
70
|
+
if (recoveryResult)
|
|
71
|
+
return recoveryResult;
|
|
72
|
+
if (request.method !== 'POST') {
|
|
73
|
+
if (path === '/' || path === '/dashboard') {
|
|
74
|
+
const granted = permissions.filter(permission => hasPermission(principal, permission));
|
|
75
|
+
const stats = hasPermission(principal, 'auth.users.read') ? await service.dashboard() : undefined;
|
|
76
|
+
const users = hasPermission(principal, 'auth.users.read') ? await service.listUsers({ limit: 50 }) : undefined;
|
|
77
|
+
const methodCounts = new Map();
|
|
78
|
+
for (const day of stats?.daily ?? [])
|
|
79
|
+
for (const entry of day.methods) {
|
|
80
|
+
const total = methodCounts.get(entry.method) ?? { signUps: 0, signIns: 0, failedSignIns: 0 };
|
|
81
|
+
total.signUps += entry.signUps;
|
|
82
|
+
total.signIns += entry.signIns;
|
|
83
|
+
total.failedSignIns += entry.failedSignIns;
|
|
84
|
+
methodCounts.set(entry.method, total);
|
|
85
|
+
}
|
|
86
|
+
const recent = hasPermission(principal, 'auth.audit.read') ? await service.listAudit({ limit: 20 }) : undefined;
|
|
87
|
+
if (wantsJson(request))
|
|
88
|
+
return jsonResponse(200, { permissions: granted, csrf, ...(users ? { accounts: stats, accountsShown: users.users.length, moreAccounts: !!users.next } : {}), ...(recent ? { recentEvents: recent.events } : {}) });
|
|
89
|
+
const number = (value) => tr('number.value', { value }), counts = (total) => ({ signUps: number(total.signUps), signIns: number(total.signIns), failedSignIns: number(total.failedSignIns) });
|
|
90
|
+
return screen('Administration', 'dashboard', {
|
|
91
|
+
impersonation: hasPermission(principal, 'auth.users.impersonate') && options.notifyImpersonation ? { summary: presentation.textSource('Start ten-minute support impersonation'), form: markup(form(mount + '/impersonate', csrf, formField('accountId', 'Account ID') + formField('reason', 'Reason'), 'Start ten-minute support impersonation')) } : null,
|
|
92
|
+
intro: tr('copy.selectASectionOnlyPermittedOperationsAreShownConfigurationRemainsInVersionControlledProjectFiles'),
|
|
93
|
+
summary: stats ? markup(dashboardSummary(stats, mount, principal, presentation)) : null,
|
|
94
|
+
activity: stats ? { heading: presentation.textSource('View activity totals'), description: tr('copy.recordedAccountCreationsSuccessfulSignInsAndFailedSignInsTheseFiguresDescribeAuthenticationActivityDeploymentH'), dailyCaption: tr('copy.dailyAuthenticationCounts'), methodsCaption: tr('copy.authenticationMethodsOverTheSame30Days'), dayHeading: tr('copy.uTCDay'), methodHeading: tr('copy.method'), signUps: tr('copy.signUps'), signIns: tr('copy.signIns'), failedSignIns: tr('copy.failedSignIns'), days: stats.daily.map(day => ({ day: day.day, ...counts(day) })), methods: [...methodCounts].map(([method, total]) => ({ method, ...counts(total) })) } : null,
|
|
95
|
+
recent: recent ? { heading: tr('copy.recentEvents'), auditHref: mount + '/audit', auditLabel: tr('nav.audit'), events: recent.events.slice(0, 8).map(event => ({ action: event.action, datetime: new Date(event.created).toISOString(), label: new Date(event.created).toISOString().replace('T', ' ').slice(0, 16) })), empty: presentation.textSource('No account activity recorded.') } : null,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (path === '/health') {
|
|
99
|
+
requirePermission(principal, 'auth.health.read');
|
|
100
|
+
const health = readHealth ? await readHealth() : null;
|
|
101
|
+
if (wantsJson(request))
|
|
102
|
+
return jsonResponse(health || !readHealth ? 200 : 503, { configured: !!readHealth, health });
|
|
103
|
+
const view = healthScreen({ health, configured: !!readHealth, mount, csrf, principal, presentation, query: request.query });
|
|
104
|
+
return screenResponse('Service health', view, { ...render(), shell });
|
|
105
|
+
}
|
|
106
|
+
if (path === '/cases') {
|
|
107
|
+
requirePermission(principal, 'auth.cases.read');
|
|
108
|
+
const result = await service.listCases({ limit: 50, ...(request.query.get('after') ? { after: request.query.get('after') } : {}) });
|
|
109
|
+
if (wantsJson(request))
|
|
110
|
+
return jsonResponse(200, { ...result, csrf });
|
|
111
|
+
return screenResponse('Support cases', casesScreen({ result, mount, csrf, principal, presentation, query: request.query }), { ...render(), shell });
|
|
112
|
+
}
|
|
113
|
+
if (path === '/registrations') {
|
|
114
|
+
requirePermission(principal, 'auth.users.manage');
|
|
115
|
+
const result = await service.listRegistrationRequests({ limit: 50, ...(request.query.get('after') ? { after: request.query.get('after') } : {}) });
|
|
116
|
+
if (wantsJson(request))
|
|
117
|
+
return jsonResponse(200, { ...result, requests: result.requests.map(item => ({ ...item, email: maskEmail(item.email) })), csrf });
|
|
118
|
+
return screenResponse('Registration requests', registrationsScreen({ result, mount, csrf, principal, presentation, query: request.query, canInvite: !!options.sendInvitation }), { ...render(), shell });
|
|
119
|
+
}
|
|
120
|
+
if (path === '/users/detail') {
|
|
121
|
+
requirePermission(principal, 'auth.users.read');
|
|
122
|
+
const account = await service.getUser(request.query.get('id') || '');
|
|
123
|
+
if (!account)
|
|
124
|
+
throw new AuthHttpError(404, 'Account not found');
|
|
125
|
+
const activity = hasPermission(principal, 'auth.audit.read') ? await service.listAudit({ limit: 20, subject: account.id }) : undefined;
|
|
126
|
+
const notes = hasPermission(principal, 'auth.audit.read') ? await service.listAudit({ limit: 50, subject: account.id, action: 'admin.note' }) : undefined;
|
|
127
|
+
const user = { ...account, email: maskEmail(account.email) }, sessions = hasPermission(principal, 'auth.sessions.manage') ? await service.listSessions(user.id) : undefined;
|
|
128
|
+
if (wantsJson(request))
|
|
129
|
+
return jsonResponse(200, { user, ...(sessions ? { sessions } : {}), ...(activity ? { activity: activity.events } : {}), csrf });
|
|
130
|
+
return screenResponse('Account details', accountDetail({ user, principal, mount, csrf, presentation, ...(sessions ? { sessions } : {}), ...(activity ? { activity } : {}), ...(notes ? { notes } : {}), operations: accounts.enabled(), recovery: recovery.enabled() }), { ...render(), shell });
|
|
131
|
+
}
|
|
132
|
+
if (path === '/users') {
|
|
133
|
+
requirePermission(principal, 'auth.users.read');
|
|
134
|
+
const result = await service.listUsers(userFilters(request.query)), users = result.users.map(user => ({ ...user, email: maskEmail(user.email) }));
|
|
135
|
+
if (wantsJson(request))
|
|
136
|
+
return jsonResponse(200, { users, ...(result.next ? { next: result.next } : {}), csrf });
|
|
137
|
+
return screenResponse('Users', userDirectory({ users, ...(result.next ? { next: result.next } : {}), query: request.query, principal, mount, csrf, presentation, canSendSetup: !!options.sendSetup }), { ...render(), shell });
|
|
138
|
+
}
|
|
139
|
+
if (path === '/roles') {
|
|
140
|
+
requirePermission(principal, 'auth.roles.read');
|
|
141
|
+
const roles = service.getRoles();
|
|
142
|
+
if (wantsJson(request))
|
|
143
|
+
return jsonResponse(200, { roles, csrf });
|
|
144
|
+
return screenResponse('Roles', rolesScreen({ roles, mount, csrf, principal, presentation, query: request.query }), { ...render(), shell });
|
|
145
|
+
}
|
|
146
|
+
if (path === '/sessions') {
|
|
147
|
+
requirePermission(principal, 'auth.sessions.manage');
|
|
148
|
+
const result = await service.listAllSessions(sessionFilters(request.query));
|
|
149
|
+
if (wantsJson(request))
|
|
150
|
+
return jsonResponse(200, { ...result, sessions: result.sessions.map(session => ({ ...session, ...('email' in session ? { email: maskEmail(String(session.email)) } : {}) })), csrf });
|
|
151
|
+
return screenResponse('Sessions', sessionsScreen({ result, mount, csrf, principal, presentation, query: request.query }), { ...render(), shell });
|
|
152
|
+
}
|
|
153
|
+
if (path === '/audit/export') {
|
|
154
|
+
requirePermission(principal, 'auth.audit.read');
|
|
155
|
+
requirePermission(principal, 'auth.audit.export');
|
|
156
|
+
return jsonResponse(200, await exportAuditRange(service, token, request.query), [['content-disposition', 'attachment; filename="audit-range.json"']]);
|
|
157
|
+
}
|
|
158
|
+
if (path === '/audit') {
|
|
159
|
+
requirePermission(principal, 'auth.audit.read');
|
|
160
|
+
const result = await service.listAudit(auditFilters(request.query));
|
|
161
|
+
if (wantsJson(request))
|
|
162
|
+
return jsonResponse(200, result);
|
|
163
|
+
return screenResponse('Audit', auditScreen({ result, mount, csrf, principal, presentation, query: request.query }), { ...render(), shell });
|
|
164
|
+
}
|
|
165
|
+
throw new AuthHttpError(404, 'Not found');
|
|
166
|
+
}
|
|
167
|
+
const fields = readFields(request, [...selectedNames(request.body, request.headers.get('content-type')), 'accountIds', 'confirmation', ...userFilterKeys, 'after', 'accountId', 'roles', 'status', 'reason', 'requestId', 'email', 'action', 'caseId', 'sessionId']);
|
|
168
|
+
http.verify(request, fields);
|
|
169
|
+
if (!fields.reason?.trim() || fields.reason.length > 256)
|
|
170
|
+
throw new AuthHttpError(400, 'A reason is required');
|
|
171
|
+
if (Date.now() - principal.authenticatedAt > 5 * 60 * 1000)
|
|
172
|
+
throw new AuthHttpError(403, 'Confirm your identity before this action');
|
|
173
|
+
if (path === '/users/note') {
|
|
174
|
+
requirePermission(principal, 'auth.users.manage');
|
|
175
|
+
await service.adminAddNote({ actorToken: token, accountId: fields.accountId || '', reason: fields.reason });
|
|
176
|
+
return wantsJson(request) ? jsonResponse(200, { saved: true }) : status('Note saved', presentation.textSource('Administrator note saved.'));
|
|
177
|
+
}
|
|
178
|
+
if (path === '/users/bulk') {
|
|
179
|
+
if (!['lock', 'unlock', 'revoke-sessions'].includes(fields.action || ''))
|
|
180
|
+
throw new AuthHttpError(400, 'Invalid bulk action');
|
|
181
|
+
const action = fields.action;
|
|
182
|
+
requirePermission(principal, action === 'revoke-sessions' ? 'auth.sessions.manage' : 'auth.users.manage');
|
|
183
|
+
const accountIds = selectedAccounts(fields);
|
|
184
|
+
if (fields.confirmation !== action.toUpperCase() + ' ' + accountIds.length)
|
|
185
|
+
throw new AuthHttpError(400, 'Typed confirmation must match the action and selected count');
|
|
186
|
+
const result = await service.adminBulk({ actorToken: token, accountIds, action, reason: fields.reason });
|
|
187
|
+
return wantsJson(request) ? jsonResponse(200, result) : status('Bulk update completed', tr('message.bulkUpdated', { count: result.affected }));
|
|
188
|
+
}
|
|
189
|
+
if (path === '/users/export-range') {
|
|
190
|
+
requirePermission(principal, 'auth.users.export');
|
|
191
|
+
requirePermission(principal, 'auth.users.read');
|
|
192
|
+
const filters = new URLSearchParams();
|
|
193
|
+
for (const key of userFilterKeys)
|
|
194
|
+
if (fields[key])
|
|
195
|
+
filters.set(key, fields[key]);
|
|
196
|
+
const body = await exportUserRange(service, token, filters, fields.reason || '');
|
|
197
|
+
const base = jsonResponse(200, {});
|
|
198
|
+
return { ...base, headers: [...base.headers.filter(([name]) => name !== 'content-type'), ['content-type', 'text/csv; charset=utf-8'], ['content-disposition', 'attachment; filename="accounts-filtered.csv"']], body };
|
|
199
|
+
}
|
|
200
|
+
if (path === '/users/export-page') {
|
|
201
|
+
requirePermission(principal, 'auth.users.export');
|
|
202
|
+
requirePermission(principal, 'auth.users.read');
|
|
203
|
+
const filterValues = new URLSearchParams();
|
|
204
|
+
for (const key of [...userFilterKeys, 'after'])
|
|
205
|
+
if (fields[key])
|
|
206
|
+
filterValues.set(key, fields[key]);
|
|
207
|
+
const page = await service.listUsers(userFilters(filterValues));
|
|
208
|
+
const users = [];
|
|
209
|
+
for (const user of page.users) {
|
|
210
|
+
const exported = await service.adminExport({ actorToken: token, accountId: user.id, reason: fields.reason });
|
|
211
|
+
users.push({ ...exported.user, ...('observedLastSeen' in user ? { observedLastSeen: user.observedLastSeen } : {}) });
|
|
212
|
+
}
|
|
213
|
+
const base = jsonResponse(200, {});
|
|
214
|
+
return { ...base, headers: [...base.headers.filter(([name]) => name !== 'content-type'), ['content-type', 'text/csv; charset=utf-8'], ['content-disposition', 'attachment; filename="accounts-page.csv"'], ...(page.next ? [['x-next-cursor', page.next]] : [])], body: usersCsv(users) };
|
|
215
|
+
}
|
|
216
|
+
if (path === '/users/reveal') {
|
|
217
|
+
requirePermission(principal, 'auth.users.read');
|
|
218
|
+
requirePermission(principal, 'auth.users.reveal');
|
|
219
|
+
const result = await service.adminReveal({ actorToken: token, accountId: fields.accountId || '', reason: fields.reason });
|
|
220
|
+
return wantsJson(request) ? jsonResponse(200, result) : screen('Account identifier', 'reveal', { idLabel: tr('field.accountId'), id: result.id, emailLabel: tr('copy.email'), email: result.email });
|
|
221
|
+
}
|
|
222
|
+
if (path === '/users/export') {
|
|
223
|
+
requirePermission(principal, 'auth.users.export');
|
|
224
|
+
return jsonResponse(200, await service.adminExport({ actorToken: token, accountId: fields.accountId || '', reason: fields.reason }), [['content-disposition', 'attachment; filename="account-export.json"']]);
|
|
225
|
+
}
|
|
226
|
+
else if (path === '/users/create') {
|
|
227
|
+
requirePermission(principal, 'auth.users.create');
|
|
228
|
+
if (!options.sendSetup)
|
|
229
|
+
throw new AuthHttpError(503, 'Setup delivery unavailable');
|
|
230
|
+
const created = await service.adminCreateUser({ actorToken: token, email: fields.email || '', reason: fields.reason });
|
|
231
|
+
await withDeadline(signal => options.sendSetup({ email: created.user.email, token: created.setupToken, signal }), 5000, 'Setup delivery timeout');
|
|
232
|
+
}
|
|
233
|
+
else if (path === '/sessions/revoke-one') {
|
|
234
|
+
requirePermission(principal, 'auth.sessions.manage');
|
|
235
|
+
await service.adminRevokeSession({ actorToken: token, sessionId: fields.sessionId || '', reason: fields.reason });
|
|
236
|
+
}
|
|
237
|
+
else if (path === '/cases/create') {
|
|
238
|
+
requirePermission(principal, 'auth.cases.manage');
|
|
239
|
+
if (!['reset-factors', 'lock', 'unlock', 'roles'].includes(fields.action || ''))
|
|
240
|
+
throw new AuthHttpError(400, 'Invalid case action');
|
|
241
|
+
await service.createCase({ actorToken: token, accountId: fields.accountId || '', action: fields.action, ...(fields.roles ? { roles: fields.roles.split(',').map(role => role.trim()).filter(Boolean) } : {}), reason: fields.reason });
|
|
242
|
+
}
|
|
243
|
+
else if (path === '/cases/note') {
|
|
244
|
+
requirePermission(principal, 'auth.cases.manage');
|
|
245
|
+
await service.addCaseNote({ actorToken: token, caseId: fields.caseId || '', note: fields.reason });
|
|
246
|
+
}
|
|
247
|
+
else if (path === '/cases/close') {
|
|
248
|
+
requirePermission(principal, 'auth.cases.manage');
|
|
249
|
+
await service.closeCase({ actorToken: token, caseId: fields.caseId || '', reason: fields.reason });
|
|
250
|
+
}
|
|
251
|
+
else if (path === '/cases/approve') {
|
|
252
|
+
requirePermission(principal, 'auth.cases.manage');
|
|
253
|
+
await service.approveCase({ actorToken: token, caseId: fields.caseId || '', reason: fields.reason });
|
|
254
|
+
}
|
|
255
|
+
else if (path === '/impersonate') {
|
|
256
|
+
requirePermission(principal, 'auth.users.impersonate');
|
|
257
|
+
if (!options.notifyImpersonation)
|
|
258
|
+
throw new AuthHttpError(503, 'Impersonation notification is required');
|
|
259
|
+
const result = await service.createImpersonation({ actorToken: token, accountId: fields.accountId || '', reason: fields.reason });
|
|
260
|
+
try {
|
|
261
|
+
await withDeadline(signal => options.notifyImpersonation({ email: result.user.email, actorId: principal.id, reason: fields.reason, signal }), 5000, 'Notification timeout');
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
await service.logout(result.token);
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
return jsonResponse(303, { impersonating: true }, [['location', authMount + '/account'], ...http.sessionHeaders(result.token)]);
|
|
268
|
+
}
|
|
269
|
+
else if (path === '/registrations/approve') {
|
|
270
|
+
requirePermission(principal, 'auth.users.manage');
|
|
271
|
+
await service.approveRegistration({ actorToken: token, requestId: fields.requestId || '', reason: fields.reason });
|
|
272
|
+
}
|
|
273
|
+
else if (path === '/invitations') {
|
|
274
|
+
requirePermission(principal, 'auth.users.create');
|
|
275
|
+
if (!options.sendInvitation)
|
|
276
|
+
throw new AuthHttpError(503, 'Invitation delivery unavailable');
|
|
277
|
+
const issued = await service.invite({ actorToken: token, email: fields.email || '' });
|
|
278
|
+
await withDeadline(signal => options.sendInvitation({ email: fields.email || '', token: issued.token, signal }), 5000, 'Delivery timeout');
|
|
279
|
+
}
|
|
280
|
+
else if (path === '/users/roles') {
|
|
281
|
+
requirePermission(principal, 'auth.users.manage');
|
|
282
|
+
const roles = (fields.roles || '').split(',').map(role => role.trim()).filter(Boolean);
|
|
283
|
+
await service.adminSetRoles({ actorToken: token, accountId: fields.accountId || '', roles, reason: fields.reason });
|
|
284
|
+
}
|
|
285
|
+
else if (path === '/users/status') {
|
|
286
|
+
requirePermission(principal, 'auth.users.manage');
|
|
287
|
+
if (fields.status !== 'active' && fields.status !== 'locked')
|
|
288
|
+
throw new AuthHttpError(400, 'Invalid status');
|
|
289
|
+
await service.adminSetStatus({ actorToken: token, accountId: fields.accountId || '', status: fields.status, reason: fields.reason });
|
|
290
|
+
}
|
|
291
|
+
else if (path === '/sessions/revoke') {
|
|
292
|
+
requirePermission(principal, 'auth.sessions.manage');
|
|
293
|
+
await service.adminRevokeSessions({ actorToken: token, accountId: fields.accountId || '', reason: fields.reason });
|
|
294
|
+
}
|
|
295
|
+
else
|
|
296
|
+
throw new AuthHttpError(404, 'Not found');
|
|
297
|
+
return wantsJson(request) ? jsonResponse(200, { updated: true }) : status('Update completed', tr('message.operationCompleted'), mount, presentation.textSource('Return to overview'));
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
return failureResponse(error, request, render());
|
|
301
|
+
}
|
|
302
|
+
} };
|
|
303
|
+
} };
|
|
304
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { initAdministration } from "./scaffold.js";
|
|
4
|
+
try {
|
|
5
|
+
const { values, positionals } = parseArgs({ allowPositionals: true, options: { directory: { type: 'string' }, help: { type: 'boolean' } } });
|
|
6
|
+
if (values.help || !positionals.length)
|
|
7
|
+
process.stdout.write('urlcode-admin init --directory NEW_DIRECTORY\nCreates a private auth/admin operator host and route project. Review before activation.\n');
|
|
8
|
+
else if (positionals.length === 1 && positionals[0] === 'init' && values.directory)
|
|
9
|
+
process.stdout.write(JSON.stringify(await initAdministration(values.directory)) + '\n');
|
|
10
|
+
else
|
|
11
|
+
throw new Error('Invalid command');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
process.stderr.write('Admin initialization failed; provide a new directory with an existing parent.\n');
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { adminExtension } from './admin.ts';
|
|
2
|
+
export type { AdminExtensionOptions } from './admin.ts';
|
|
3
|
+
export { initAdministration, scaffold } from './scaffold.ts';
|
|
4
|
+
export type { ScaffoldRequest, ScaffoldFile, ScaffoldResult } from './scaffold.ts';
|
|
5
|
+
export type { AdminHealthProvider, AdminHealthSnapshot, HealthStatus } from './admin-health.ts';
|
|
6
|
+
export { withSupportBanner } from './support-banner.ts';
|
|
7
|
+
export type { SupportBannerOptions } from './support-banner.ts';
|
|
8
|
+
export { createAdministrationRuntime } from './admin-runtime.ts';
|
|
9
|
+
export type { AdministrationRuntimeOptions } from './admin-runtime.ts';
|
|
10
|
+
export { createAdminPresentation, adminCatalogue } from './admin-copy.ts';
|
|
11
|
+
export { adminTemplates, adminTemplateNames, adminUiTemplates } from './admin-templates.ts';
|
|
12
|
+
export type { AdminTemplate } from './admin-templates.ts';
|
|
13
|
+
export type { Screen, ScreenOptions, UiHost } from './admin-ui.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { adminExtension } from "./admin.js";
|
|
2
|
+
export { initAdministration, scaffold } from "./scaffold.js";
|
|
3
|
+
export { withSupportBanner } from "./support-banner.js";
|
|
4
|
+
export { createAdministrationRuntime } from "./admin-runtime.js";
|
|
5
|
+
export { createAdminPresentation, adminCatalogue } from "./admin-copy.js";
|
|
6
|
+
export { adminTemplates, adminTemplateNames, adminUiTemplates } from "./admin-templates.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Shared scaffold contract (core `urlcode init --with`, auth, admin): what the caller has decided so far. */
|
|
2
|
+
export interface ScaffoldRequest {
|
|
3
|
+
/** Absolute output directory the caller will create; nothing is written by `scaffold`. */
|
|
4
|
+
directory: string;
|
|
5
|
+
/** Absolute route-project directory (contains urlcode.yaml). */
|
|
6
|
+
project: string;
|
|
7
|
+
/** Absolute host module path the caller will write. */
|
|
8
|
+
hostFile: string;
|
|
9
|
+
/** Every extension name being composed, in host order. */
|
|
10
|
+
names: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export interface ScaffoldFile {
|
|
13
|
+
path: string;
|
|
14
|
+
content: string | Uint8Array;
|
|
15
|
+
mode?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ScaffoldResult {
|
|
18
|
+
name: string;
|
|
19
|
+
extensions: Record<string, unknown>;
|
|
20
|
+
routes: Record<string, unknown>;
|
|
21
|
+
hostImports: string[];
|
|
22
|
+
hostSetup: string[];
|
|
23
|
+
hostEntries: string[];
|
|
24
|
+
hostClose?: string[];
|
|
25
|
+
files: ScaffoldFile[];
|
|
26
|
+
readme: string;
|
|
27
|
+
nextSteps: string[];
|
|
28
|
+
env?: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
/** Describes admin's contribution to a composed project without writing anything. Requires the auth extension in the same host. */
|
|
31
|
+
export declare function scaffold(request: ScaffoldRequest): Promise<ScaffoldResult>;
|
|
32
|
+
/** Separate operator host and route project; both extensions remain explicitly pinned. */
|
|
33
|
+
export declare function initAdministration(directory: string): Promise<{
|
|
34
|
+
directory: string;
|
|
35
|
+
project: string;
|
|
36
|
+
hostFile: string;
|
|
37
|
+
operatorFile: string;
|
|
38
|
+
}>;
|