@adonis-agora/authkit-server 0.90.2 → 1.0.0-rc.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/CHANGELOG.md +3654 -0
- package/build/commands/clients_create.d.ts +1 -0
- package/build/commands/clients_create.js +15 -2
- package/build/commands/commands.json +7 -0
- package/build/host/ui/admin.html +1 -1
- package/build/host/views/account/apps.edge +8 -3
- package/build/host/views/partials/styles.edge +1 -1
- package/build/index.d.ts +5 -3
- package/build/index.js +4 -5
- package/build/providers/authkit_server_provider.js +8 -0
- package/build/src/accounts/lucid_account_store.d.ts +0 -18
- package/build/src/accounts/lucid_account_store.js +7 -48
- package/build/src/accounts/lucid_store/mfa.js +1 -1
- package/build/src/accounts/lucid_store/organizations.d.ts +1 -1
- package/build/src/accounts/lucid_store/password_hygiene.d.ts +2 -3
- package/build/src/accounts/lucid_store/password_hygiene.js +2 -3
- package/build/src/accounts/lucid_store/shared.d.ts +0 -7
- package/build/src/accounts/lucid_store/shared.js +0 -14
- package/build/src/accounts/lucid_stores.d.ts +1 -1
- package/build/src/adapters/redis_adapter.js +12 -2
- package/build/src/console_authorize.d.ts +56 -0
- package/build/src/console_authorize.js +85 -0
- package/build/src/define_config.d.ts +26 -14
- package/build/src/define_config.js +5 -3
- package/build/src/doctor/checks.d.ts +10 -5
- package/build/src/doctor/checks.js +52 -8
- package/build/src/host/account_api/account_api_controller.d.ts +2 -1
- package/build/src/host/account_api/account_api_controller.js +5 -11
- package/build/src/host/admin_api/api_clients_controller.d.ts +9 -3
- package/build/src/host/admin_api/dto.d.ts +3 -1
- package/build/src/host/admin_api/dto.js +3 -1
- package/build/src/host/admin_clients_service.d.ts +13 -5
- package/build/src/host/admin_clients_service.js +28 -6
- package/build/src/host/admin_console/admin_shell_controller.d.ts +16 -1
- package/build/src/host/admin_console/admin_shell_controller.js +21 -7
- package/build/src/host/admin_console/console_clients_controller.d.ts +21 -2
- package/build/src/host/admin_console/console_clients_controller.js +9 -0
- package/build/src/host/admin_console/console_impersonation_controller.js +3 -8
- package/build/src/host/admin_console/console_overview_controller.d.ts +2 -2
- package/build/src/host/admin_console/console_overview_controller.js +4 -2
- package/build/src/host/admin_console_warning.d.ts +8 -0
- package/build/src/host/admin_console_warning.js +18 -0
- package/build/src/host/admin_validators.d.ts +15 -11
- package/build/src/host/admin_validators.js +16 -9
- package/build/src/host/auth_host_config.d.ts +5 -0
- package/build/src/host/client_names.d.ts +9 -2
- package/build/src/host/client_names.js +11 -3
- package/build/src/host/consent_params.d.ts +7 -0
- package/build/src/host/consent_params.js +27 -0
- package/build/src/host/console_denied_page.d.ts +6 -0
- package/build/src/host/console_denied_page.js +18 -0
- package/build/src/host/console_sso.d.ts +24 -0
- package/build/src/host/console_sso.js +70 -0
- package/build/src/host/controllers/account_apps_controller.js +4 -3
- package/build/src/host/controllers/account_confirm_controller.d.ts +0 -7
- package/build/src/host/controllers/account_confirm_controller.js +0 -6
- package/build/src/host/controllers/account_orgs_controller.d.ts +0 -50
- package/build/src/host/controllers/account_orgs_controller.js +7 -113
- package/build/src/host/controllers/account_security_controller.js +7 -11
- package/build/src/host/controllers/account_session_controller.js +10 -1
- package/build/src/host/controllers/agent_consent_controller.js +23 -5
- package/build/src/host/controllers/console_sso_controller.d.ts +12 -0
- package/build/src/host/controllers/console_sso_controller.js +61 -0
- package/build/src/host/controllers/interaction_controller.js +9 -3
- package/build/src/host/controllers/poppy_consent_controller.js +33 -7
- package/build/src/host/controllers/registration_controller.js +7 -2
- package/build/src/host/i18n.d.ts +0 -4
- package/build/src/host/i18n.js +0 -6
- package/build/src/host/impersonation.d.ts +7 -15
- package/build/src/host/impersonation.js +7 -16
- package/build/src/host/interaction_recovery.js +4 -1
- package/build/src/host/login_attempt.js +5 -1
- package/build/src/host/middleware/account_auth.js +6 -0
- package/build/src/host/rate_limit.js +5 -1
- package/build/src/host/register_auth_host.d.ts +8 -0
- package/build/src/host/register_auth_host.js +116 -11
- package/build/src/host/renderers/inertia_renderer.d.ts +9 -21
- package/build/src/host/renderers/inertia_renderer.js +5 -6
- package/build/src/host/screen_props.d.ts +309 -0
- package/build/src/host/screen_props.js +11 -0
- package/build/src/host/sudo_mode.d.ts +5 -11
- package/build/src/host/sudo_mode.js +5 -11
- package/build/src/host/ui-dist/assets/index-D0EIeyKK.js +67 -0
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/password/password_manager.js +5 -0
- package/build/src/provider/build_provider.js +6 -34
- package/build/src/provider/oidc_service.d.ts +0 -2
- package/build/src/provider/oidc_service.js +1 -12
- package/build/src/provider/token_exchange.d.ts +1 -3
- package/build/src/provider/token_exchange.js +1 -1
- package/build/stubs/config/authkit_react.stub +3 -3
- package/package.json +12 -11
- package/skills/authkit-interactions/SKILL.md +32 -5
- package/stubs/config/authkit_react.stub +3 -3
- package/build/src/commands/import_clients.d.ts +0 -40
- package/build/src/commands/import_clients.js +0 -81
- package/build/src/host/ui-dist/assets/index-DUVBDAyh.js +0 -67
- package/build/src/mixins/with_mfa.d.ts +0 -35
- package/build/src/mixins/with_mfa.js +0 -21
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { assertClientMetadata } from './client_metadata.js';
|
|
2
|
+
import { assertClientMetadata, ClientMetadataError, } from './client_metadata.js';
|
|
3
|
+
import { CONSOLE_SSO_CLIENT_ID } from './console_sso.js';
|
|
3
4
|
/** Métodos públicos (sem segredo) — espelham os "non-secret" do oidc-provider. */
|
|
4
5
|
const PUBLIC_AUTH_METHODS = new Set(['none']);
|
|
5
6
|
/** Gera um identificador opaco no estilo do oidc-provider (~43 chars base64url). */
|
|
@@ -51,6 +52,7 @@ export class AdminClientsService {
|
|
|
51
52
|
*/
|
|
52
53
|
async create(input) {
|
|
53
54
|
const clientId = (input.clientId ?? '').trim() || randomId();
|
|
55
|
+
assertNotReservedClientId(clientId);
|
|
54
56
|
const confidential = !PUBLIC_AUTH_METHODS.has(input.tokenEndpointAuthMethod);
|
|
55
57
|
const clientSecret = confidential ? randomId() : undefined;
|
|
56
58
|
const payload = this.#buildPayload(clientId, input, clientSecret);
|
|
@@ -60,15 +62,15 @@ export class AdminClientsService {
|
|
|
60
62
|
return { clientId, clientSecret };
|
|
61
63
|
}
|
|
62
64
|
/**
|
|
63
|
-
* Cria um client
|
|
64
|
-
*
|
|
65
|
-
* secret original deve ser preservado para não quebrar apps já configurados.
|
|
65
|
+
* Cria um client com um `clientSecret` fornecido externamente (em vez de gerar
|
|
66
|
+
* um aleatório) — ex.: para manter o secret que os RPs já usam.
|
|
66
67
|
*
|
|
67
68
|
* Para clients públicos (auth method `none`), `providedSecret` é ignorado.
|
|
68
69
|
* Lança se o `clientId` já existir no adapter — chame `find` antes.
|
|
69
70
|
*/
|
|
70
71
|
async createWithSecret(input, providedSecret) {
|
|
71
72
|
const clientId = (input.clientId ?? '').trim() || randomId();
|
|
73
|
+
assertNotReservedClientId(clientId);
|
|
72
74
|
const confidential = !PUBLIC_AUTH_METHODS.has(input.tokenEndpointAuthMethod);
|
|
73
75
|
const clientSecret = confidential ? (providedSecret ?? randomId()) : undefined;
|
|
74
76
|
const payload = this.#buildPayload(clientId, input, clientSecret);
|
|
@@ -98,9 +100,17 @@ export class AdminClientsService {
|
|
|
98
100
|
existing.token_endpoint_auth_method ??
|
|
99
101
|
'client_secret_basic',
|
|
100
102
|
applicationType: input.applicationType ?? existing.application_type ?? 'web',
|
|
101
|
-
backchannelLogoutUri: input.backchannelLogoutUri
|
|
103
|
+
backchannelLogoutUri: input.backchannelLogoutUri === null
|
|
104
|
+
? undefined
|
|
105
|
+
: (input.backchannelLogoutUri ?? existing.backchannel_logout_uri),
|
|
102
106
|
backchannelLogoutSessionRequired: input.backchannelLogoutSessionRequired ??
|
|
103
107
|
existing.backchannel_logout_session_required,
|
|
108
|
+
clientName: input.clientName === null
|
|
109
|
+
? undefined
|
|
110
|
+
: (input.clientName ?? existing.client_name),
|
|
111
|
+
logoUri: input.logoUri === null
|
|
112
|
+
? undefined
|
|
113
|
+
: (input.logoUri ?? existing.logo_uri),
|
|
104
114
|
};
|
|
105
115
|
const confidential = !PUBLIC_AUTH_METHODS.has(merged.tokenEndpointAuthMethod);
|
|
106
116
|
// Mantém o secret atual se continua confidencial; se virou public, remove-o.
|
|
@@ -173,6 +183,10 @@ export class AdminClientsService {
|
|
|
173
183
|
// sempre (sem `application_type` o oidc-provider assume `web`).
|
|
174
184
|
if (input.applicationType === 'native')
|
|
175
185
|
payload.application_type = 'native';
|
|
186
|
+
if (input.clientName)
|
|
187
|
+
payload.client_name = input.clientName;
|
|
188
|
+
if (input.logoUri)
|
|
189
|
+
payload.logo_uri = input.logoUri;
|
|
176
190
|
if (input.backchannelLogoutUri) {
|
|
177
191
|
payload.backchannel_logout_uri = input.backchannelLogoutUri;
|
|
178
192
|
if (input.backchannelLogoutSessionRequired !== undefined) {
|
|
@@ -188,11 +202,13 @@ export class AdminClientsService {
|
|
|
188
202
|
const result = {
|
|
189
203
|
clientId: row.clientId,
|
|
190
204
|
confidential: !!p.client_secret,
|
|
191
|
-
|
|
205
|
+
grantTypes: p.grant_types ?? ['authorization_code', 'refresh_token'],
|
|
192
206
|
redirectUris: p.redirect_uris ?? [],
|
|
193
207
|
postLogoutRedirectUris: p.post_logout_redirect_uris ?? [],
|
|
194
208
|
tokenEndpointAuthMethod: authMethod,
|
|
195
209
|
applicationType: p.application_type === 'native' ? 'native' : 'web',
|
|
210
|
+
clientName: typeof p.client_name === 'string' && p.client_name ? p.client_name : null,
|
|
211
|
+
logoUri: typeof p.logo_uri === 'string' && p.logo_uri ? p.logo_uri : null,
|
|
196
212
|
};
|
|
197
213
|
if (p.backchannel_logout_uri) {
|
|
198
214
|
result.backchannelLogoutUri = p.backchannel_logout_uri;
|
|
@@ -202,3 +218,9 @@ export class AdminClientsService {
|
|
|
202
218
|
return result;
|
|
203
219
|
}
|
|
204
220
|
}
|
|
221
|
+
/** O `client_id` do client interno do console (ver `console_sso.ts`) não pode ser cadastrado. */
|
|
222
|
+
function assertNotReservedClientId(clientId) {
|
|
223
|
+
if (clientId === CONSOLE_SSO_CLIENT_ID) {
|
|
224
|
+
throw new ClientMetadataError(`client_id "${clientId}" é reservado pelo authkit`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
/** Id of the JSON data block the SPA reads its config from. */
|
|
3
|
+
export declare const ADMIN_CONFIG_ELEMENT_ID = "authkit-admin-config";
|
|
4
|
+
/**
|
|
5
|
+
* Serializes the config for an HTML data block: `<`, `>`, `&` and the JS line
|
|
6
|
+
* separators are escaped so no value can close the `<script>` element.
|
|
7
|
+
*/
|
|
8
|
+
export declare function serializeConfigBlock(config: Record<string, unknown>): string;
|
|
9
|
+
/**
|
|
10
|
+
* Embeds the config as a `<script type="application/json">` data block right
|
|
11
|
+
* after `<body>`. A data block is never executed, so a strict CSP
|
|
12
|
+
* (`script-src 'self' 'nonce-…'`) leaves it alone; the SPA parses it.
|
|
13
|
+
* Also rewrites `/__AUTHKIT_BASE__/` → actual adminBase so all asset URLs
|
|
14
|
+
* produced by Vite (base: '/__AUTHKIT_BASE__/') resolve correctly at runtime.
|
|
15
|
+
*/
|
|
16
|
+
export declare function prepareShell(html: string, config: Record<string, unknown>): string;
|
|
2
17
|
/**
|
|
3
18
|
* Controla o serving do console admin React (Missão B).
|
|
4
19
|
*
|
|
@@ -6,7 +21,7 @@ import type { HttpContext } from '@adonisjs/core/http';
|
|
|
6
21
|
* `serveAsset` — serve arquivos estáticos do build (JS/CSS/fonts/images) com cache.
|
|
7
22
|
*
|
|
8
23
|
* O shell HTML é o index.html produzido pelo Vite build em `build/host/ui-dist/`.
|
|
9
|
-
* O servidor
|
|
24
|
+
* O servidor embute a config num data block JSON (`#authkit-admin-config`) e reescreve o placeholder de base dinâmica
|
|
10
25
|
* `/__AUTHKIT_BASE__/` → `${adminBase}/assets/` para que todos os imports de assets
|
|
11
26
|
* Vite resolvam corretamente independente do prefixo admin configurado.
|
|
12
27
|
*
|
|
@@ -50,19 +50,33 @@ then restart the server.</p>
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
// ─── Config injection ─────────────────────────────────────────────────────────
|
|
53
|
+
/** Id of the JSON data block the SPA reads its config from. */
|
|
54
|
+
export const ADMIN_CONFIG_ELEMENT_ID = 'authkit-admin-config';
|
|
53
55
|
/**
|
|
54
|
-
*
|
|
56
|
+
* Serializes the config for an HTML data block: `<`, `>`, `&` and the JS line
|
|
57
|
+
* separators are escaped so no value can close the `<script>` element.
|
|
58
|
+
*/
|
|
59
|
+
export function serializeConfigBlock(config) {
|
|
60
|
+
return JSON.stringify(config)
|
|
61
|
+
.replace(/</g, '\\u003c')
|
|
62
|
+
.replace(/>/g, '\\u003e')
|
|
63
|
+
.replace(/&/g, '\\u0026')
|
|
64
|
+
.replace(/\u2028/g, '\\u2028')
|
|
65
|
+
.replace(/\u2029/g, '\\u2029');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Embeds the config as a `<script type="application/json">` data block right
|
|
69
|
+
* after `<body>`. A data block is never executed, so a strict CSP
|
|
70
|
+
* (`script-src 'self' 'nonce-…'`) leaves it alone; the SPA parses it.
|
|
55
71
|
* Also rewrites `/__AUTHKIT_BASE__/` → actual adminBase so all asset URLs
|
|
56
72
|
* produced by Vite (base: '/__AUTHKIT_BASE__/') resolve correctly at runtime.
|
|
57
73
|
*/
|
|
58
|
-
function prepareShell(html, config) {
|
|
74
|
+
export function prepareShell(html, config) {
|
|
59
75
|
const adminBase = config.adminBase;
|
|
60
76
|
// 1. Rewrite the Vite placeholder base in ALL occurrences (src/href attributes).
|
|
61
|
-
// Vite puts `/__AUTHKIT_BASE__/assets/foo.js` so we rewrite `/__AUTHKIT_BASE__/`
|
|
62
|
-
// to `${adminBase}/` so asset URLs become `${adminBase}/assets/foo.js`.
|
|
63
77
|
const rewritten = html.replaceAll('/__AUTHKIT_BASE__/', `${adminBase}/`);
|
|
64
|
-
// 2. Inject config
|
|
65
|
-
const script = `<script
|
|
78
|
+
// 2. Inject the config data block right after <body>.
|
|
79
|
+
const script = `<script type="application/json" id="${ADMIN_CONFIG_ELEMENT_ID}">${serializeConfigBlock(config)}</script>`;
|
|
66
80
|
const bodyIdx = rewritten.indexOf('<body');
|
|
67
81
|
if (bodyIdx === -1)
|
|
68
82
|
return script + rewritten;
|
|
@@ -97,7 +111,7 @@ const MIME = {
|
|
|
97
111
|
* `serveAsset` — serve arquivos estáticos do build (JS/CSS/fonts/images) com cache.
|
|
98
112
|
*
|
|
99
113
|
* O shell HTML é o index.html produzido pelo Vite build em `build/host/ui-dist/`.
|
|
100
|
-
* O servidor
|
|
114
|
+
* O servidor embute a config num data block JSON (`#authkit-admin-config`) e reescreve o placeholder de base dinâmica
|
|
101
115
|
* `/__AUTHKIT_BASE__/` → `${adminBase}/assets/` para que todos os imports de assets
|
|
102
116
|
* Vite resolvam corretamente independente do prefixo admin configurado.
|
|
103
117
|
*
|
|
@@ -5,6 +5,7 @@ import type { HttpContext } from '@adonisjs/core/http';
|
|
|
5
5
|
*
|
|
6
6
|
* GET {prefix}/api/clients → lista clients dinâmicos
|
|
7
7
|
* POST {prefix}/api/clients → criar client (secret retornado UMA vez)
|
|
8
|
+
* GET {prefix}/api/clients/:id → um client (404 se não existir)
|
|
8
9
|
* PATCH {prefix}/api/clients/:id → atualizar metadata
|
|
9
10
|
* DELETE {prefix}/api/clients/:id → remover client
|
|
10
11
|
* POST {prefix}/api/clients/:id/regenerate-secret → gerar novo secret
|
|
@@ -15,16 +16,32 @@ export default class ConsoleClientsController {
|
|
|
15
16
|
data: {
|
|
16
17
|
clientId: string;
|
|
17
18
|
confidential: boolean;
|
|
18
|
-
|
|
19
|
+
grantTypes: string[];
|
|
19
20
|
redirectUris: string[];
|
|
20
21
|
postLogoutRedirectUris: string[];
|
|
21
22
|
tokenEndpointAuthMethod: string;
|
|
22
23
|
applicationType: import("../client_metadata.js").ApplicationType;
|
|
23
24
|
backchannelLogoutUri: string | null;
|
|
24
25
|
backchannelLogoutSessionRequired: boolean;
|
|
26
|
+
clientName: string | null;
|
|
27
|
+
logoUri: string | null;
|
|
25
28
|
}[];
|
|
26
29
|
canList: boolean;
|
|
27
30
|
}>;
|
|
31
|
+
/** GET {prefix}/api/clients/:id */
|
|
32
|
+
show(ctx: HttpContext): Promise<void | {
|
|
33
|
+
clientId: string;
|
|
34
|
+
confidential: boolean;
|
|
35
|
+
grantTypes: string[];
|
|
36
|
+
redirectUris: string[];
|
|
37
|
+
postLogoutRedirectUris: string[];
|
|
38
|
+
tokenEndpointAuthMethod: string;
|
|
39
|
+
applicationType: import("../client_metadata.js").ApplicationType;
|
|
40
|
+
backchannelLogoutUri: string | null;
|
|
41
|
+
backchannelLogoutSessionRequired: boolean;
|
|
42
|
+
clientName: string | null;
|
|
43
|
+
logoUri: string | null;
|
|
44
|
+
}>;
|
|
28
45
|
/** POST {prefix}/api/clients */
|
|
29
46
|
store(ctx: HttpContext): Promise<void | {
|
|
30
47
|
clientId: string;
|
|
@@ -34,13 +51,15 @@ export default class ConsoleClientsController {
|
|
|
34
51
|
update(ctx: HttpContext): Promise<void | {
|
|
35
52
|
clientId: string;
|
|
36
53
|
confidential: boolean;
|
|
37
|
-
|
|
54
|
+
grantTypes: string[];
|
|
38
55
|
redirectUris: string[];
|
|
39
56
|
postLogoutRedirectUris: string[];
|
|
40
57
|
tokenEndpointAuthMethod: string;
|
|
41
58
|
applicationType: import("../client_metadata.js").ApplicationType;
|
|
42
59
|
backchannelLogoutUri: string | null;
|
|
43
60
|
backchannelLogoutSessionRequired: boolean;
|
|
61
|
+
clientName: string | null;
|
|
62
|
+
logoUri: string | null;
|
|
44
63
|
}>;
|
|
45
64
|
/** DELETE {prefix}/api/clients/:id */
|
|
46
65
|
destroy(ctx: HttpContext): Promise<void | {
|
|
@@ -15,6 +15,7 @@ function invalidMetadata(ctx, err) {
|
|
|
15
15
|
*
|
|
16
16
|
* GET {prefix}/api/clients → lista clients dinâmicos
|
|
17
17
|
* POST {prefix}/api/clients → criar client (secret retornado UMA vez)
|
|
18
|
+
* GET {prefix}/api/clients/:id → um client (404 se não existir)
|
|
18
19
|
* PATCH {prefix}/api/clients/:id → atualizar metadata
|
|
19
20
|
* DELETE {prefix}/api/clients/:id → remover client
|
|
20
21
|
* POST {prefix}/api/clients/:id/regenerate-secret → gerar novo secret
|
|
@@ -30,6 +31,14 @@ export default class ConsoleClientsController {
|
|
|
30
31
|
const clients = await svc.list();
|
|
31
32
|
return { data: clients.map(clientDto), canList: true };
|
|
32
33
|
}
|
|
34
|
+
/** GET {prefix}/api/clients/:id */
|
|
35
|
+
async show(ctx) {
|
|
36
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
37
|
+
const client = await new AdminClientsService(service).find(ctx.request.param('id'));
|
|
38
|
+
if (!client)
|
|
39
|
+
return ctx.response.notFound(apiError('not_found', 'Client não encontrado.'));
|
|
40
|
+
return clientDto(client);
|
|
41
|
+
}
|
|
33
42
|
/** POST {prefix}/api/clients */
|
|
34
43
|
async store(ctx) {
|
|
35
44
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
@@ -49,11 +49,7 @@ export default class ConsoleImpersonationController {
|
|
|
49
49
|
if (!account) {
|
|
50
50
|
return ctx.response.notFound(apiError('not_found', 'Usuário não encontrado.'));
|
|
51
51
|
}
|
|
52
|
-
// Clients
|
|
53
|
-
// vivem no adapter, criados pelo console/Admin API/`authkit:clients:create`.
|
|
54
|
-
// Ler só o config fazia o painel devolver 404 SEMPRE, que é por que o botão
|
|
55
|
-
// do console nunca funcionou. O fallback para o config cobre hosts legados
|
|
56
|
-
// com clients estáticos e adapters que não enumeram.
|
|
52
|
+
// Clients vivem no adapter, criados pelo console/Admin API/`authkit:clients:create`.
|
|
57
53
|
const panel = buildImpersonationPanel(cfg, targetId, await listRuntimeClients(service));
|
|
58
54
|
if (!panel) {
|
|
59
55
|
return ctx.response.notFound(apiError('no_token_exchange_client', 'Nenhum client habilitado ao grant token-exchange encontrado.'));
|
|
@@ -83,8 +79,7 @@ export default class ConsoleImpersonationController {
|
|
|
83
79
|
}
|
|
84
80
|
/**
|
|
85
81
|
* Clients persistidos que podem hospedar o token-exchange. Best-effort: um
|
|
86
|
-
* adapter sem `list` (ou que falhe) devolve lista vazia e o
|
|
87
|
-
* `buildImpersonationPanel` cai no `cfg.clients`.
|
|
82
|
+
* adapter sem `list` (ou que falhe) devolve lista vazia e o painel responde 404.
|
|
88
83
|
*/
|
|
89
84
|
async function listRuntimeClients(service) {
|
|
90
85
|
try {
|
|
@@ -93,7 +88,7 @@ async function listRuntimeClients(service) {
|
|
|
93
88
|
return [];
|
|
94
89
|
return (await clients.list()).map((c) => ({
|
|
95
90
|
clientId: c.clientId,
|
|
96
|
-
|
|
91
|
+
grantTypes: c.grantTypes,
|
|
97
92
|
confidential: c.confidential,
|
|
98
93
|
}));
|
|
99
94
|
}
|
|
@@ -5,7 +5,7 @@ import type { HttpContext } from '@adonisjs/core/http';
|
|
|
5
5
|
*
|
|
6
6
|
* Métricas-resumo do IdP para o dashboard do console React. Reutiliza
|
|
7
7
|
* `computeAdminStats` (mesma fonte do console Edge) e adiciona:
|
|
8
|
-
* - `clientsCount` — número de clients
|
|
8
|
+
* - `clientsCount` — número de clients no adapter (0 quando o adapter não enumera)
|
|
9
9
|
* - `auditTotal` — total de eventos de auditoria (0 quando sink não suporta)
|
|
10
10
|
* - `recentEvents` — últimos 5 eventos de auditoria ([] quando não suporta)
|
|
11
11
|
*/
|
|
@@ -20,7 +20,7 @@ export default class ConsoleOverviewController {
|
|
|
20
20
|
signUpsPerDay: import("../admin_stats_service.js").DailyPoint[];
|
|
21
21
|
windowDays: number;
|
|
22
22
|
auditSupported: boolean;
|
|
23
|
-
clientsCount:
|
|
23
|
+
clientsCount: number;
|
|
24
24
|
auditTotal: any;
|
|
25
25
|
recentEvents: any;
|
|
26
26
|
}>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import '../augmentations.js';
|
|
2
2
|
import { LIST_FIRST_PAGE } from '../../pagination.js';
|
|
3
|
+
import { AdminClientsService } from '../admin_clients_service.js';
|
|
3
4
|
import { AdminSessionsService } from '../admin_sessions_service.js';
|
|
4
5
|
import { computeAdminStats } from '../admin_stats_service.js';
|
|
5
6
|
/**
|
|
@@ -7,7 +8,7 @@ import { computeAdminStats } from '../admin_stats_service.js';
|
|
|
7
8
|
*
|
|
8
9
|
* Métricas-resumo do IdP para o dashboard do console React. Reutiliza
|
|
9
10
|
* `computeAdminStats` (mesma fonte do console Edge) e adiciona:
|
|
10
|
-
* - `clientsCount` — número de clients
|
|
11
|
+
* - `clientsCount` — número de clients no adapter (0 quando o adapter não enumera)
|
|
11
12
|
* - `auditTotal` — total de eventos de auditoria (0 quando sink não suporta)
|
|
12
13
|
* - `recentEvents` — últimos 5 eventos de auditoria ([] quando não suporta)
|
|
13
14
|
*/
|
|
@@ -17,7 +18,8 @@ export default class ConsoleOverviewController {
|
|
|
17
18
|
const cfg = service.config;
|
|
18
19
|
const sessions = new AdminSessionsService(service);
|
|
19
20
|
const stats = await computeAdminStats(cfg, sessions);
|
|
20
|
-
const
|
|
21
|
+
const clients = new AdminClientsService(service);
|
|
22
|
+
const clientsCount = clients.canList ? (await clients.list()).length : 0;
|
|
21
23
|
const recentResult = typeof cfg.audit?.list === 'function'
|
|
22
24
|
? await cfg.audit.list({ page: LIST_FIRST_PAGE, size: 5 })
|
|
23
25
|
: null;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The boot warning for an admin console with no `admin.authorize`. Same wording in every console. */
|
|
2
|
+
export declare const ADMIN_CONSOLE_NO_AUTHORIZE_WARNING = "[@adonis-agora/authkit-server] console has no `authorize` configured: it is open outside production and denies every request in production. Set `authorize` in config/authkit.ts.";
|
|
3
|
+
/** Logs {@link ADMIN_CONSOLE_NO_AUTHORIZE_WARNING} once per process. */
|
|
4
|
+
export declare function warnAdminConsoleUnauthorized(logger: {
|
|
5
|
+
warn: (message: string) => unknown;
|
|
6
|
+
} | null | undefined): boolean;
|
|
7
|
+
/** Test helper: lets the warning fire again. */
|
|
8
|
+
export declare function resetAdminConsoleWarning(): void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The boot warning for an admin console with no `admin.authorize`. Same wording in every console. */
|
|
2
|
+
export const ADMIN_CONSOLE_NO_AUTHORIZE_WARNING = '[@adonis-agora/authkit-server] console has no `authorize` configured: it is open outside production and denies every request in production. Set `authorize` in config/authkit.ts.';
|
|
3
|
+
let warned = false;
|
|
4
|
+
/** Logs {@link ADMIN_CONSOLE_NO_AUTHORIZE_WARNING} once per process. */
|
|
5
|
+
export function warnAdminConsoleUnauthorized(logger) {
|
|
6
|
+
if (warned)
|
|
7
|
+
return false;
|
|
8
|
+
warned = true;
|
|
9
|
+
if (logger)
|
|
10
|
+
logger.warn(ADMIN_CONSOLE_NO_AUTHORIZE_WARNING);
|
|
11
|
+
else
|
|
12
|
+
console.warn(ADMIN_CONSOLE_NO_AUTHORIZE_WARNING);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
/** Test helper: lets the warning fire again. */
|
|
16
|
+
export function resetAdminConsoleWarning() {
|
|
17
|
+
warned = false;
|
|
18
|
+
}
|
|
@@ -4,8 +4,7 @@ import { type ApplicationType } from './client_metadata.js';
|
|
|
4
4
|
* Input de client OIDC — serve create E update (PATCH). Tudo opcional: no create
|
|
5
5
|
* o controller aplica defaults (ver {@link clientCreateInput}); no update os campos
|
|
6
6
|
* ausentes (undefined) são preservados pelo `AdminClientsService.update` (merge,
|
|
7
|
-
* ver {@link clientPartialInput}).
|
|
8
|
-
* (o DTO de saída usa `grants`).
|
|
7
|
+
* ver {@link clientPartialInput}).
|
|
9
8
|
*/
|
|
10
9
|
export declare const clientInputValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
|
|
11
10
|
clientId: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineString>;
|
|
@@ -13,37 +12,41 @@ export declare const clientInputValidator: import("@vinejs/vine").VineValidator<
|
|
|
13
12
|
redirectUris: import("@vinejs/vine").OptionalModifier<import("@vinejs/vine").VineArray<import("@vinejs/vine").VineString>>;
|
|
14
13
|
postLogoutRedirectUris: import("@vinejs/vine").OptionalModifier<import("@vinejs/vine").VineArray<import("@vinejs/vine").VineString>>;
|
|
15
14
|
grantTypes: import("@vinejs/vine").OptionalModifier<import("@vinejs/vine").VineArray<import("@vinejs/vine").VineEnum<readonly ["authorization_code", "refresh_token", "client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"]>>>;
|
|
16
|
-
grants: import("@vinejs/vine").OptionalModifier<import("@vinejs/vine").VineArray<import("@vinejs/vine").VineEnum<readonly ["authorization_code", "refresh_token", "client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"]>>>;
|
|
17
15
|
tokenEndpointAuthMethod: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineEnum<readonly ["client_secret_basic", "client_secret_post", "none"]>>;
|
|
18
|
-
backchannelLogoutUri: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineString
|
|
16
|
+
backchannelLogoutUri: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine/schema/base/literal").NullableModifier<import("@vinejs/vine").VineString>>;
|
|
19
17
|
backchannelLogoutSessionRequired: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineBoolean>;
|
|
18
|
+
clientName: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine/schema/base/literal").NullableModifier<import("@vinejs/vine").VineString>>;
|
|
19
|
+
logoUri: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine/schema/base/literal").NullableModifier<import("@vinejs/vine").VineString>>;
|
|
20
20
|
}, {
|
|
21
21
|
applicationType?: "native" | "web" | null | undefined;
|
|
22
22
|
backchannelLogoutSessionRequired?: string | number | boolean | null | undefined;
|
|
23
23
|
backchannelLogoutUri?: string | null | undefined;
|
|
24
24
|
clientId?: string | null | undefined;
|
|
25
|
+
clientName?: string | null | undefined;
|
|
25
26
|
grantTypes?: ("authorization_code" | "client_credentials" | "refresh_token" | "urn:ietf:params:oauth:grant-type:token-exchange")[] | null | undefined;
|
|
26
|
-
|
|
27
|
+
logoUri?: string | null | undefined;
|
|
27
28
|
postLogoutRedirectUris?: string[] | null | undefined;
|
|
28
29
|
redirectUris?: string[] | null | undefined;
|
|
29
30
|
tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post" | "none" | null | undefined;
|
|
30
31
|
}, {
|
|
31
32
|
applicationType?: "native" | "web" | undefined;
|
|
32
33
|
backchannelLogoutSessionRequired?: boolean | undefined;
|
|
33
|
-
backchannelLogoutUri?: string | undefined;
|
|
34
|
+
backchannelLogoutUri?: string | null | undefined;
|
|
34
35
|
clientId?: string | undefined;
|
|
36
|
+
clientName?: string | null | undefined;
|
|
35
37
|
grantTypes?: ("authorization_code" | "client_credentials" | "refresh_token" | "urn:ietf:params:oauth:grant-type:token-exchange")[] | undefined;
|
|
36
|
-
|
|
38
|
+
logoUri?: string | null | undefined;
|
|
37
39
|
postLogoutRedirectUris?: string[] | undefined;
|
|
38
40
|
redirectUris?: string[] | undefined;
|
|
39
41
|
tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post" | "none" | undefined;
|
|
40
42
|
}, {
|
|
41
43
|
applicationType?: "native" | "web" | undefined;
|
|
42
44
|
backchannelLogoutSessionRequired?: boolean | undefined;
|
|
43
|
-
backchannelLogoutUri?: string | undefined;
|
|
45
|
+
backchannelLogoutUri?: string | null | undefined;
|
|
44
46
|
clientId?: string | undefined;
|
|
47
|
+
clientName?: string | null | undefined;
|
|
45
48
|
grantTypes?: ("authorization_code" | "client_credentials" | "refresh_token" | "urn:ietf:params:oauth:grant-type:token-exchange")[] | undefined;
|
|
46
|
-
|
|
49
|
+
logoUri?: string | null | undefined;
|
|
47
50
|
postLogoutRedirectUris?: string[] | undefined;
|
|
48
51
|
redirectUris?: string[] | undefined;
|
|
49
52
|
tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post" | "none" | undefined;
|
|
@@ -55,10 +58,11 @@ export type ClientInputFields = {
|
|
|
55
58
|
redirectUris?: string[];
|
|
56
59
|
postLogoutRedirectUris?: string[];
|
|
57
60
|
grantTypes?: string[];
|
|
58
|
-
grants?: string[];
|
|
59
61
|
tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
|
|
60
|
-
backchannelLogoutUri?: string;
|
|
62
|
+
backchannelLogoutUri?: string | null;
|
|
61
63
|
backchannelLogoutSessionRequired?: boolean;
|
|
64
|
+
clientName?: string | null;
|
|
65
|
+
logoUri?: string | null;
|
|
62
66
|
};
|
|
63
67
|
/**
|
|
64
68
|
* Mapeia o input validado para um {@link ClientInput} COMPLETO (create), com defaults.
|
|
@@ -83,8 +83,7 @@ const grantTypeArray = vine.array(vine.enum(ALLOWED_GRANT_TYPES));
|
|
|
83
83
|
* Input de client OIDC — serve create E update (PATCH). Tudo opcional: no create
|
|
84
84
|
* o controller aplica defaults (ver {@link clientCreateInput}); no update os campos
|
|
85
85
|
* ausentes (undefined) são preservados pelo `AdminClientsService.update` (merge,
|
|
86
|
-
* ver {@link clientPartialInput}).
|
|
87
|
-
* (o DTO de saída usa `grants`).
|
|
86
|
+
* ver {@link clientPartialInput}).
|
|
88
87
|
*/
|
|
89
88
|
export const clientInputValidator = vine.compile(vine.object({
|
|
90
89
|
clientId: vine.string().trim().optional(),
|
|
@@ -96,11 +95,14 @@ export const clientInputValidator = vine.compile(vine.object({
|
|
|
96
95
|
postLogoutRedirectUris: redirectUriArray.optional(),
|
|
97
96
|
// grant_types restrito à allowlist; `implicit` e desconhecidos viram 422 (M10).
|
|
98
97
|
grantTypes: grantTypeArray.optional(),
|
|
99
|
-
grants: grantTypeArray.optional(),
|
|
100
98
|
tokenEndpointAuthMethod: authMethod.optional(),
|
|
101
99
|
// backchannel_logout_uri também precisa ser URI absoluta (L10).
|
|
102
|
-
|
|
100
|
+
// `null` (update) remove a URI.
|
|
101
|
+
backchannelLogoutUri: vine.string().trim().url(URL_OPTS).nullable().optional(),
|
|
103
102
|
backchannelLogoutSessionRequired: vine.boolean().optional(),
|
|
103
|
+
// `null` (update) removes them.
|
|
104
|
+
clientName: vine.string().trim().maxLength(200).nullable().optional(),
|
|
105
|
+
logoUri: vine.string().trim().url(URL_OPTS).nullable().optional(),
|
|
104
106
|
}));
|
|
105
107
|
/**
|
|
106
108
|
* Mapeia o input validado para um {@link ClientInput} COMPLETO (create), com defaults.
|
|
@@ -114,10 +116,12 @@ export function clientCreateInput(v) {
|
|
|
114
116
|
applicationType,
|
|
115
117
|
redirectUris: v.redirectUris ?? [],
|
|
116
118
|
postLogoutRedirectUris: v.postLogoutRedirectUris ?? [],
|
|
117
|
-
grantTypes: v.grantTypes ??
|
|
119
|
+
grantTypes: v.grantTypes ?? [],
|
|
118
120
|
tokenEndpointAuthMethod: v.tokenEndpointAuthMethod ?? (applicationType === 'native' ? 'none' : 'client_secret_basic'),
|
|
119
121
|
backchannelLogoutUri: v.backchannelLogoutUri || undefined,
|
|
120
122
|
backchannelLogoutSessionRequired: v.backchannelLogoutSessionRequired,
|
|
123
|
+
clientName: v.clientName || undefined,
|
|
124
|
+
logoUri: v.logoUri || undefined,
|
|
121
125
|
};
|
|
122
126
|
}
|
|
123
127
|
/**
|
|
@@ -132,15 +136,18 @@ export function clientPartialInput(v) {
|
|
|
132
136
|
out.redirectUris = v.redirectUris;
|
|
133
137
|
if (v.postLogoutRedirectUris !== undefined)
|
|
134
138
|
out.postLogoutRedirectUris = v.postLogoutRedirectUris;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
out.grantTypes = grants;
|
|
139
|
+
if (v.grantTypes !== undefined)
|
|
140
|
+
out.grantTypes = v.grantTypes;
|
|
138
141
|
if (v.tokenEndpointAuthMethod !== undefined)
|
|
139
142
|
out.tokenEndpointAuthMethod = v.tokenEndpointAuthMethod;
|
|
140
143
|
if (v.backchannelLogoutUri !== undefined)
|
|
141
|
-
out.backchannelLogoutUri = v.backchannelLogoutUri
|
|
144
|
+
out.backchannelLogoutUri = v.backchannelLogoutUri?.trim() || null;
|
|
142
145
|
if (v.backchannelLogoutSessionRequired !== undefined)
|
|
143
146
|
out.backchannelLogoutSessionRequired = v.backchannelLogoutSessionRequired;
|
|
147
|
+
if (v.clientName !== undefined)
|
|
148
|
+
out.clientName = v.clientName?.trim() || null;
|
|
149
|
+
if (v.logoUri !== undefined)
|
|
150
|
+
out.logoUri = v.logoUri?.trim() || null;
|
|
144
151
|
return out;
|
|
145
152
|
}
|
|
146
153
|
// ─── Usuários ───────────────────────────────────────────────────────────────
|
|
@@ -40,6 +40,11 @@ export interface AuthHostRuntimeConfig {
|
|
|
40
40
|
prefix: string;
|
|
41
41
|
poppyPrefix?: string;
|
|
42
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* `accountSession.acceptIdpSession: false` com `authMethods: { password: false }`:
|
|
45
|
+
* o console só entra por um `accountLoginUrl` do host (`registerAuthHost` recusa sem ele).
|
|
46
|
+
*/
|
|
47
|
+
consoleNeedsLoginUrl?: boolean;
|
|
43
48
|
/**
|
|
44
49
|
* API headless (Clerk-style). Presente quando o host a declarou — o provider o
|
|
45
50
|
* stash no boot para `registerAuthHost` montar as rotas sem reler o config inteiro.
|
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
* Nome de exibição de cada client: o `client_name` que ele registrou (um cliente MCP registra o
|
|
3
3
|
* seu, ex.: "Claude Code"), senão o próprio `client_id`. Client que sumiu do adapter → o id.
|
|
4
4
|
*/
|
|
5
|
-
export declare function clientDisplayNames(service:
|
|
5
|
+
export declare function clientDisplayNames(service: ClientLookup, clientIds: string[]): Promise<Map<string, string>>;
|
|
6
|
+
type ClientLookup = {
|
|
6
7
|
provider: {
|
|
7
8
|
Client: {
|
|
8
9
|
find(id: string): Promise<any>;
|
|
9
10
|
};
|
|
10
11
|
};
|
|
11
|
-
}
|
|
12
|
+
};
|
|
13
|
+
/** Nome de exibição e logo (`logo_uri` registrado, ou `null`) de cada client. */
|
|
14
|
+
export declare function clientDisplayInfo(service: ClientLookup, clientIds: string[]): Promise<Map<string, {
|
|
15
|
+
name: string;
|
|
16
|
+
logoUrl: string | null;
|
|
17
|
+
}>>;
|
|
18
|
+
export {};
|
|
@@ -3,10 +3,18 @@
|
|
|
3
3
|
* seu, ex.: "Claude Code"), senão o próprio `client_id`. Client que sumiu do adapter → o id.
|
|
4
4
|
*/
|
|
5
5
|
export async function clientDisplayNames(service, clientIds) {
|
|
6
|
-
const
|
|
6
|
+
const info = await clientDisplayInfo(service, clientIds);
|
|
7
|
+
return new Map([...info].map(([id, v]) => [id, v.name]));
|
|
8
|
+
}
|
|
9
|
+
/** Nome de exibição e logo (`logo_uri` registrado, ou `null`) de cada client. */
|
|
10
|
+
export async function clientDisplayInfo(service, clientIds) {
|
|
11
|
+
const info = new Map();
|
|
7
12
|
await Promise.all([...new Set(clientIds)].map(async (clientId) => {
|
|
8
13
|
const client = await service.provider.Client.find(clientId).catch(() => null);
|
|
9
|
-
|
|
14
|
+
info.set(clientId, {
|
|
15
|
+
name: client?.clientName || clientId,
|
|
16
|
+
logoUrl: typeof client?.logoUri === 'string' && client.logoUri ? client.logoUri : null,
|
|
17
|
+
});
|
|
10
18
|
}));
|
|
11
|
-
return
|
|
19
|
+
return info;
|
|
12
20
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ConsentRequestParams } from './screen_props.js';
|
|
2
|
+
/**
|
|
3
|
+
* The interaction's request parameters for the consent screen: `client_id` and the
|
|
4
|
+
* standard string parameters are guaranteed strings (or absent); everything else is
|
|
5
|
+
* passed through untouched.
|
|
6
|
+
*/
|
|
7
|
+
export declare function consentRequestParams(raw: Record<string, unknown> | undefined): ConsentRequestParams;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Optional standard parameters of an authorization request, typed as strings. */
|
|
2
|
+
const CONSENT_STRING_PARAMS = [
|
|
3
|
+
'redirect_uri',
|
|
4
|
+
'response_type',
|
|
5
|
+
'scope',
|
|
6
|
+
'state',
|
|
7
|
+
'prompt',
|
|
8
|
+
'nonce',
|
|
9
|
+
'code_challenge',
|
|
10
|
+
'code_challenge_method',
|
|
11
|
+
];
|
|
12
|
+
/**
|
|
13
|
+
* The interaction's request parameters for the consent screen: `client_id` and the
|
|
14
|
+
* standard string parameters are guaranteed strings (or absent); everything else is
|
|
15
|
+
* passed through untouched.
|
|
16
|
+
*/
|
|
17
|
+
export function consentRequestParams(raw) {
|
|
18
|
+
const params = { ...raw, client_id: String(raw?.client_id ?? '') };
|
|
19
|
+
for (const key of CONSENT_STRING_PARAMS) {
|
|
20
|
+
const value = raw?.[key];
|
|
21
|
+
if (typeof value === 'string')
|
|
22
|
+
params[key] = value;
|
|
23
|
+
else
|
|
24
|
+
delete params[key];
|
|
25
|
+
}
|
|
26
|
+
return params;
|
|
27
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page a browser gets when `admin.authorize` denies the admin console
|
|
3
|
+
* (403), or asks for a sign-in it cannot do itself (401). Self-contained: no
|
|
4
|
+
* script and no external asset, so any CSP renders it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function consoleDeniedPage(status: 401 | 403): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page a browser gets when `admin.authorize` denies the admin console
|
|
3
|
+
* (403), or asks for a sign-in it cannot do itself (401). Self-contained: no
|
|
4
|
+
* script and no external asset, so any CSP renders it.
|
|
5
|
+
*/
|
|
6
|
+
export function consoleDeniedPage(status) {
|
|
7
|
+
const title = status === 401 ? 'Sign in required' : 'Access denied';
|
|
8
|
+
const body = status === 401
|
|
9
|
+
? 'This console needs credentials this request did not carry.'
|
|
10
|
+
: 'You do not have access to this console.';
|
|
11
|
+
return `<!doctype html>
|
|
12
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
13
|
+
<title>${title}</title>
|
|
14
|
+
<style>body{font-family:system-ui,sans-serif;background:#f9fafb;color:#111827;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:16px}
|
|
15
|
+
.box{max-width:420px;text-align:center}h1{font-size:20px;margin:0 0 8px}p{color:#4b5563;font-size:14px;line-height:1.6;margin:0}
|
|
16
|
+
.code{font-size:12px;color:#9ca3af;margin-bottom:8px}</style>
|
|
17
|
+
</head><body><main class="box"><p class="code">${status}</p><h1>${title}</h1><p>${body}</p></main></body></html>`;
|
|
18
|
+
}
|