@dudousxd/adonis-authkit-server 0.2.0 → 0.4.0

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.
Files changed (61) hide show
  1. package/build/commands/commands.json +28 -0
  2. package/build/commands/doctor.d.ts +10 -0
  3. package/build/commands/doctor.js +66 -0
  4. package/build/commands/rotate_keys.d.ts +10 -0
  5. package/build/commands/rotate_keys.js +53 -0
  6. package/build/host/views/account/email-confirmed.edge +15 -0
  7. package/build/host/views/account/security.edge +83 -0
  8. package/build/host/views/account/tokens.edge +7 -4
  9. package/build/host/views/admin/client_form.edge +83 -0
  10. package/build/host/views/admin/clients.edge +68 -3
  11. package/build/host/views/admin/sessions.edge +89 -0
  12. package/build/host/views/admin/users.edge +1 -0
  13. package/build/host/views/mfa-challenge.edge +29 -23
  14. package/build/index.d.ts +4 -3
  15. package/build/index.js +2 -2
  16. package/build/src/accounts/account_store.d.ts +46 -1
  17. package/build/src/accounts/account_store.js +4 -0
  18. package/build/src/accounts/lucid_store/core.d.ts +5 -4
  19. package/build/src/accounts/lucid_store/core.js +67 -2
  20. package/build/src/adapters/adapter_contract.d.ts +29 -0
  21. package/build/src/adapters/database_adapter.d.ts +12 -1
  22. package/build/src/adapters/database_adapter.js +24 -0
  23. package/build/src/adapters/redis_adapter.d.ts +14 -1
  24. package/build/src/adapters/redis_adapter.js +35 -0
  25. package/build/src/audit/audit_sink.d.ts +1 -1
  26. package/build/src/define_config.d.ts +102 -0
  27. package/build/src/define_config.js +46 -3
  28. package/build/src/doctor/checks.d.ts +51 -0
  29. package/build/src/doctor/checks.js +231 -0
  30. package/build/src/host/admin_clients_service.d.ts +65 -0
  31. package/build/src/host/admin_clients_service.js +143 -0
  32. package/build/src/host/admin_sessions_service.d.ts +63 -0
  33. package/build/src/host/admin_sessions_service.js +127 -0
  34. package/build/src/host/controllers/account_security_controller.d.ts +16 -0
  35. package/build/src/host/controllers/account_security_controller.js +119 -0
  36. package/build/src/host/controllers/account_session_controller.js +2 -1
  37. package/build/src/host/controllers/admin/admin_clients_controller.d.ts +17 -3
  38. package/build/src/host/controllers/admin/admin_clients_controller.js +158 -4
  39. package/build/src/host/controllers/admin/admin_sessions_controller.d.ts +14 -0
  40. package/build/src/host/controllers/admin/admin_sessions_controller.js +64 -0
  41. package/build/src/host/controllers/interaction_controller.d.ts +11 -0
  42. package/build/src/host/controllers/interaction_controller.js +49 -10
  43. package/build/src/host/default_mailer.d.ts +17 -0
  44. package/build/src/host/default_mailer.js +51 -0
  45. package/build/src/host/i18n.d.ts +80 -0
  46. package/build/src/host/i18n.js +86 -1
  47. package/build/src/host/login_notify.d.ts +20 -0
  48. package/build/src/host/login_notify.js +71 -0
  49. package/build/src/host/register_auth_host.js +20 -0
  50. package/build/src/host/validators.d.ts +32 -0
  51. package/build/src/host/validators.js +14 -0
  52. package/build/src/keys/keystore.d.ts +43 -0
  53. package/build/src/keys/keystore.js +74 -0
  54. package/build/src/provider/build_provider.js +23 -0
  55. package/build/src/provider/device_sources.d.ts +6 -0
  56. package/build/src/provider/device_sources.js +65 -0
  57. package/build/src/provider/interaction_actions.d.ts +6 -1
  58. package/build/src/provider/interaction_actions.js +9 -2
  59. package/build/src/provider/oidc_service.d.ts +15 -0
  60. package/build/src/provider/oidc_service.js +27 -0
  61. package/package.json +2 -2
@@ -25,6 +25,34 @@
25
25
  "args": [],
26
26
  "options": {},
27
27
  "filePath": "eject.js"
28
+ },
29
+ {
30
+ "commandName": "authkit:doctor",
31
+ "description": "Valida a configuração do AuthKit no host e imprime achados (✅/⚠️/❌). Sai com código !=0 se houver erros.",
32
+ "namespace": "authkit",
33
+ "aliases": [],
34
+ "flags": [],
35
+ "args": [],
36
+ "options": { "startApp": true },
37
+ "filePath": "doctor.js"
38
+ },
39
+ {
40
+ "commandName": "authkit:rotate-keys",
41
+ "description": "Rotaciona as chaves de assinatura JWKS managed: gera uma nova chave (novo kid), mantém as N anteriores no JWKS para validar tokens antigos.",
42
+ "namespace": "authkit",
43
+ "aliases": [],
44
+ "flags": [
45
+ {
46
+ "name": "keep",
47
+ "flagName": "keep",
48
+ "required": false,
49
+ "type": "number",
50
+ "description": "Quantas chaves manter no JWKS (default 2)."
51
+ }
52
+ ],
53
+ "args": [],
54
+ "options": { "startApp": true },
55
+ "filePath": "rotate_keys.js"
28
56
  }
29
57
  ]
30
58
  }
@@ -0,0 +1,10 @@
1
+ import { BaseCommand } from '@adonisjs/core/ace';
2
+ import type { CommandOptions } from '@adonisjs/core/types/ace';
3
+ export default class AuthkitDoctor extends BaseCommand {
4
+ static commandName: string;
5
+ static description: string;
6
+ static help: string[];
7
+ static options: CommandOptions;
8
+ run(): Promise<void>;
9
+ private print;
10
+ }
@@ -0,0 +1,66 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { BaseCommand } from '@adonisjs/core/ace';
10
+ import { runAllChecks, hasErrors } from '../src/doctor/checks.js';
11
+ /** Tenta importar um peer; true se importável. */
12
+ async function canImport(specifier) {
13
+ try {
14
+ await import(__rewriteRelativeImportExtension(specifier));
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ export default class AuthkitDoctor extends BaseCommand {
22
+ static commandName = 'authkit:doctor';
23
+ static description = 'Valida a configuração do AuthKit no host e imprime achados (✅/⚠️/❌). Sai com código !=0 se houver erros.';
24
+ static help = [
25
+ 'Roda uma bateria de checagens sobre a config `authkit` do host:',
26
+ 'issuer/mountPath, clients, accountStore + capacidades, session, shield,',
27
+ 'ally (social), rate-limit, admin, webauthn e jwks.',
28
+ ];
29
+ static options = { startApp: true };
30
+ async run() {
31
+ const config = await this.app.container.make('config');
32
+ const authkitConfig = config.get('authkit', null) ?? null;
33
+ const sessionConfig = config.get('session', null) ?? null;
34
+ const input = {
35
+ authkitConfig,
36
+ sessionConfig,
37
+ peers: {
38
+ session: await canImport('@adonisjs/session'),
39
+ shield: await canImport('@adonisjs/shield'),
40
+ ally: await canImport('@adonisjs/ally'),
41
+ limiter: await canImport('@adonisjs/limiter'),
42
+ },
43
+ };
44
+ const findings = runAllChecks(input);
45
+ this.print(findings);
46
+ if (hasErrors(findings)) {
47
+ this.exitCode = 1;
48
+ }
49
+ }
50
+ print(findings) {
51
+ const icon = (l) => (l === 'ok' ? '✅' : l === 'warn' ? '⚠️ ' : '❌');
52
+ this.logger.info('AuthKit doctor — checagem da configuração do host\n');
53
+ for (const f of findings) {
54
+ const line = `${icon(f.level)} ${f.message}`;
55
+ if (f.level === 'error')
56
+ this.logger.logError(line);
57
+ else if (f.level === 'warn')
58
+ this.logger.warning(line);
59
+ else
60
+ this.logger.success(line);
61
+ }
62
+ const errors = findings.filter((f) => f.level === 'error').length;
63
+ const warns = findings.filter((f) => f.level === 'warn').length;
64
+ this.logger.info(`\nResumo: ${errors} erro(s), ${warns} aviso(s).`);
65
+ }
66
+ }
@@ -0,0 +1,10 @@
1
+ import { BaseCommand } from '@adonisjs/core/ace';
2
+ import type { CommandOptions } from '@adonisjs/core/types/ace';
3
+ export default class AuthkitRotateKeys extends BaseCommand {
4
+ static commandName: string;
5
+ static description: string;
6
+ static help: string[];
7
+ static options: CommandOptions;
8
+ keep?: number;
9
+ run(): Promise<void>;
10
+ }
@@ -0,0 +1,53 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { BaseCommand, flags } from '@adonisjs/core/ace';
8
+ import { rotateKeystore } from '../src/keys/keystore.js';
9
+ export default class AuthkitRotateKeys extends BaseCommand {
10
+ static commandName = 'authkit:rotate-keys';
11
+ static description = 'Rotaciona as chaves de assinatura JWKS managed: gera uma nova chave (novo kid), mantém as N anteriores no JWKS para validar tokens antigos.';
12
+ static help = [
13
+ 'Requer `jwks: { source: "managed", store: "<arquivo>" }` na config authkit.',
14
+ 'A chave nova vira a de assinatura corrente; as `--keep` mais recentes são',
15
+ 'preservadas no JWKS público para que tokens emitidos antes ainda validem.',
16
+ ];
17
+ static options = { startApp: true };
18
+ async run() {
19
+ const config = await this.app.container.make('config');
20
+ const authkitConfig = config.get('authkit', null);
21
+ if (!authkitConfig?.jwks) {
22
+ this.logger.logError("❌ config('authkit').jwks ausente.");
23
+ this.exitCode = 1;
24
+ return;
25
+ }
26
+ const { source, store, algorithm } = authkitConfig.jwks;
27
+ if (source !== 'managed') {
28
+ this.logger.logError('❌ Rotação só se aplica a jwks.source = "managed".');
29
+ this.exitCode = 1;
30
+ return;
31
+ }
32
+ if (!store) {
33
+ this.logger.logError('❌ jwks.store não configurado. A rotação exige um keystore persistido em arquivo ' +
34
+ '(ex.: jwks: { source: "managed", store: "tmp/authkit_jwks.json" }). ' +
35
+ 'Sem store, o modo managed gera uma chave efêmera por boot e rotacionar não tem efeito.');
36
+ this.exitCode = 1;
37
+ return;
38
+ }
39
+ const storePath = this.app.makePath(store);
40
+ const alg = algorithm ?? 'RS256';
41
+ const keep = this.keep ?? 2;
42
+ const { newKid, retiredKids, store: result } = await rotateKeystore(storePath, alg, keep);
43
+ this.logger.success(`✅ Nova chave de assinatura gerada: kid=${newKid} (alg=${alg}).`);
44
+ this.logger.info(`JWKS agora serve ${result.keys.length} chave(s) (kids: ${result.keys.map((k) => k.kid).join(', ')}).`);
45
+ if (retiredKids.length) {
46
+ this.logger.warning(`⚠️ Chaves aposentadas (tokens assinados por elas deixarão de validar): ${retiredKids.join(', ')}`);
47
+ }
48
+ this.logger.info('Reinicie o processo (ou recarregue a config) para passar a assinar com a nova chave.');
49
+ }
50
+ }
51
+ __decorate([
52
+ flags.number({ description: 'Quantas chaves manter no JWKS (default 2).' })
53
+ ], AuthkitRotateKeys.prototype, "keep", void 0);
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('account.email_confirmed.page_title') }}</title>
3
+ <script src="https://cdn.tailwindcss.com"></script></head>
4
+ <body class="min-h-screen flex items-center justify-center bg-gray-100 p-4">
5
+ <div class="w-full max-w-sm rounded-2xl bg-white p-8 text-center shadow-xl ring-1 ring-black/5">
6
+ <div class="text-xs font-semibold uppercase tracking-widest text-gray-400">{{ t('common.brand_eyebrow') }}</div>
7
+ @if(ok)
8
+ <h1 class="mt-2 text-xl font-semibold text-emerald-700">{{ t('account.email_confirmed.ok_title') }}</h1>
9
+ <p class="mt-2 text-sm text-gray-600">{{ t('account.email_confirmed.ok_body') }}</p>
10
+ @else
11
+ <h1 class="mt-2 text-xl font-semibold text-red-700">{{ t('account.email_confirmed.invalid_title') }}</h1>
12
+ <p class="mt-2 text-sm text-gray-600">{{ t('account.email_confirmed.invalid_body') }}</p>
13
+ @end
14
+ </div>
15
+ </body></html>
@@ -0,0 +1,83 @@
1
+ <!doctype html>
2
+ <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('account.security.page_title') }}</title>
3
+ <script src="https://cdn.tailwindcss.com"></script></head>
4
+ <body class="min-h-screen bg-gray-100 p-4">
5
+ <div class="mx-auto max-w-2xl">
6
+ <div class="flex items-center justify-between py-6">
7
+ <div>
8
+ <div class="text-xs font-semibold uppercase tracking-widest text-gray-400">{{ t('common.brand_eyebrow') }}</div>
9
+ <h1 class="text-xl font-semibold text-gray-900">{{ t('account.security.title') }}</h1>
10
+ @if(email)
11
+ <p class="mt-1 text-sm text-gray-500">{{ t('account.security.current_email', { email: email }) }}</p>
12
+ @end
13
+ </div>
14
+ <form method="POST" action="/account/logout">
15
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
16
+ <button type="submit" class="text-sm text-gray-500 hover:underline">{{ t('account.security.logout') }}</button>
17
+ </form>
18
+ </div>
19
+
20
+ @if(!supported)
21
+ <div class="rounded-xl bg-white p-6 text-sm text-gray-500 shadow-sm ring-1 ring-black/5">
22
+ {{ t('account.security.not_supported') }}
23
+ </div>
24
+ @else
25
+ @if(error)
26
+ <div class="mb-6 rounded-lg border border-red-300 bg-red-50 p-4">
27
+ <p class="text-sm font-medium text-red-900">{{ error }}</p>
28
+ </div>
29
+ @end
30
+ @if(passwordChanged)
31
+ <div class="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4">
32
+ <p class="text-sm font-medium text-emerald-900">{{ passwordChanged }}</p>
33
+ </div>
34
+ @end
35
+ @if(emailChangeRequested)
36
+ <div class="mb-6 rounded-lg border border-blue-300 bg-blue-50 p-4">
37
+ <p class="text-sm font-medium text-blue-900">{{ emailChangeRequested }}</p>
38
+ </div>
39
+ @end
40
+
41
+ <div class="mb-6 rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
42
+ <h2 class="mb-4 text-sm font-semibold text-gray-900">{{ t('account.security.password_section') }}</h2>
43
+ <form method="POST" action="/account/security/password" class="space-y-4">
44
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
45
+ <div>
46
+ <label class="mb-1 block text-sm font-medium text-gray-700">{{ t('account.security.current_password_label') }}</label>
47
+ <input name="currentPassword" type="password" required
48
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
49
+ </div>
50
+ <div>
51
+ <label class="mb-1 block text-sm font-medium text-gray-700">{{ t('account.security.new_password_label') }}</label>
52
+ <input name="newPassword" type="password" required minlength="8"
53
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
54
+ </div>
55
+ <button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
56
+ {{ t('account.security.change_password_submit') }}
57
+ </button>
58
+ </form>
59
+ </div>
60
+
61
+ <div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
62
+ <h2 class="mb-1 text-sm font-semibold text-gray-900">{{ t('account.security.email_section') }}</h2>
63
+ <p class="mb-4 text-xs text-gray-500">{{ t('account.security.email_intro') }}</p>
64
+ <form method="POST" action="/account/security/email" class="space-y-4">
65
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
66
+ <div>
67
+ <label class="mb-1 block text-sm font-medium text-gray-700">{{ t('account.security.new_email_label') }}</label>
68
+ <input name="newEmail" type="email" required
69
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
70
+ </div>
71
+ <div>
72
+ <label class="mb-1 block text-sm font-medium text-gray-700">{{ t('account.security.email_password_label') }}</label>
73
+ <input name="currentPassword" type="password" required
74
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
75
+ </div>
76
+ <button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
77
+ {{ t('account.security.change_email_submit') }}
78
+ </button>
79
+ </form>
80
+ </div>
81
+ @end
82
+ </div>
83
+ </body></html>
@@ -8,10 +8,13 @@
8
8
  <div class="text-xs font-semibold uppercase tracking-widest text-gray-400">{{ t('common.brand_eyebrow') }}</div>
9
9
  <h1 class="text-xl font-semibold text-gray-900">{{ t('account.tokens.title') }}</h1>
10
10
  </div>
11
- <form method="POST" action="/account/logout">
12
- <input type="hidden" name="_csrf" value="{{ csrfToken }}">
13
- <button type="submit" class="text-sm text-gray-500 hover:underline">{{ t('account.tokens.logout') }}</button>
14
- </form>
11
+ <div class="flex items-center gap-4">
12
+ <a href="/account/security" class="text-sm text-gray-500 hover:underline">{{ t('account.tokens.security') }}</a>
13
+ <form method="POST" action="/account/logout">
14
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
15
+ <button type="submit" class="text-sm text-gray-500 hover:underline">{{ t('account.tokens.logout') }}</button>
16
+ </form>
17
+ </div>
15
18
  </div>
16
19
 
17
20
  @if(createdToken)
@@ -0,0 +1,83 @@
1
+ <!doctype html>
2
+ <html lang="pt-br"><head><meta charset="utf-8"><title>{{ mode === 'edit' ? t('admin.clients.edit_title') : t('admin.clients.new_title') }}</title>
3
+ <script src="https://cdn.tailwindcss.com"></script></head>
4
+ <body class="min-h-screen bg-gray-100 p-4">
5
+ <div class="mx-auto max-w-2xl">
6
+ <div class="flex items-center justify-between py-6">
7
+ <div>
8
+ <div class="text-xs font-semibold uppercase tracking-widest text-gray-400">{{ t('common.brand_eyebrow') }}</div>
9
+ <h1 class="text-xl font-semibold text-gray-900">{{ mode === 'edit' ? t('admin.clients.edit_title') : t('admin.clients.new_title') }}</h1>
10
+ </div>
11
+ <a href="/admin/clients" class="text-sm text-gray-500 hover:underline">{{ t('admin.clients.back') }}</a>
12
+ </div>
13
+
14
+ <form
15
+ method="POST"
16
+ action="{{ mode === 'edit' ? `/admin/clients/${client.clientId}/edit` : '/admin/clients' }}"
17
+ class="space-y-5 rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5"
18
+ >
19
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
20
+
21
+ <div>
22
+ <label class="block text-sm font-medium text-gray-700">{{ t('admin.clients.field_client_id') }}</label>
23
+ @if(mode === 'edit')
24
+ <input type="text" value="{{ client.clientId }}" disabled
25
+ class="mt-1 w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 font-mono text-sm text-gray-500" />
26
+ @else
27
+ <input type="text" name="client_id" value="{{ client.clientId }}" placeholder="{{ t('admin.clients.field_client_id_placeholder') }}"
28
+ class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm outline-none focus:border-gray-900" />
29
+ <p class="mt-1 text-xs text-gray-400">{{ t('admin.clients.field_client_id_help') }}</p>
30
+ @end
31
+ </div>
32
+
33
+ <div>
34
+ <label class="block text-sm font-medium text-gray-700">{{ t('admin.clients.field_redirect_uris') }}</label>
35
+ <textarea name="redirect_uris" rows="3" placeholder="https://app.exemplo.com/callback"
36
+ class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm outline-none focus:border-gray-900">{{ client.redirectUris.join('\n') }}</textarea>
37
+ <p class="mt-1 text-xs text-gray-400">{{ t('admin.clients.field_redirect_uris_help') }}</p>
38
+ </div>
39
+
40
+ <div>
41
+ <label class="block text-sm font-medium text-gray-700">{{ t('admin.clients.field_post_logout_uris') }}</label>
42
+ <textarea name="post_logout_redirect_uris" rows="2"
43
+ class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm outline-none focus:border-gray-900">{{ client.postLogoutRedirectUris.join('\n') }}</textarea>
44
+ <p class="mt-1 text-xs text-gray-400">{{ t('admin.clients.field_post_logout_uris_help') }}</p>
45
+ </div>
46
+
47
+ <div>
48
+ <span class="block text-sm font-medium text-gray-700">{{ t('admin.clients.field_grant_types') }}</span>
49
+ <div class="mt-2 space-y-1">
50
+ <label class="flex items-center gap-2 text-sm text-gray-700">
51
+ <input type="checkbox" name="grant_types" value="authorization_code" {{ client.grants.includes('authorization_code') ? 'checked' : '' }}>
52
+ authorization_code
53
+ </label>
54
+ <label class="flex items-center gap-2 text-sm text-gray-700">
55
+ <input type="checkbox" name="grant_types" value="refresh_token" {{ client.grants.includes('refresh_token') ? 'checked' : '' }}>
56
+ refresh_token
57
+ </label>
58
+ <label class="flex items-center gap-2 text-sm text-gray-700">
59
+ <input type="checkbox" name="grant_types" value="client_credentials" {{ client.grants.includes('client_credentials') ? 'checked' : '' }}>
60
+ client_credentials
61
+ </label>
62
+ </div>
63
+ </div>
64
+
65
+ <div>
66
+ <label class="block text-sm font-medium text-gray-700">{{ t('admin.clients.field_auth_method') }}</label>
67
+ <select name="token_endpoint_auth_method"
68
+ class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900">
69
+ <option value="client_secret_basic" {{ client.tokenEndpointAuthMethod === 'client_secret_basic' ? 'selected' : '' }}>client_secret_basic</option>
70
+ <option value="client_secret_post" {{ client.tokenEndpointAuthMethod === 'client_secret_post' ? 'selected' : '' }}>client_secret_post</option>
71
+ <option value="none" {{ client.tokenEndpointAuthMethod === 'none' ? 'selected' : '' }}>none ({{ t('admin.clients.public') }})</option>
72
+ </select>
73
+ </div>
74
+
75
+ <div class="flex items-center justify-end gap-2 pt-2">
76
+ <a href="/admin/clients" class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">{{ t('admin.clients.cancel') }}</a>
77
+ <button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-semibold text-white hover:bg-gray-800">
78
+ {{ mode === 'edit' ? t('admin.clients.save') : t('admin.clients.create') }}
79
+ </button>
80
+ </div>
81
+ </form>
82
+ </div>
83
+ </body></html>
@@ -21,17 +21,30 @@
21
21
  <a href="/admin/audit" class="text-gray-500 hover:underline">{{ t('admin.nav.audit') }}</a>
22
22
  </nav>
23
23
 
24
+ @if(createdSecret)
25
+ <div class="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4 text-sm text-emerald-900">
26
+ <p class="font-semibold">{{ t('admin.clients.secret_once_title') }}</p>
27
+ <p class="mt-1">{{ t('admin.clients.secret_once_notice') }}</p>
28
+ <p class="mt-2 break-all font-mono text-xs">
29
+ <span class="text-emerald-700">client_id:</span> {{ createdSecret.clientId }}<br>
30
+ <span class="text-emerald-700">client_secret:</span> {{ createdSecret.clientSecret }}
31
+ </p>
32
+ </div>
33
+ @end
34
+
24
35
  @if(dynamicEnabled)
25
36
  <div class="mb-6 rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
26
37
  {{ t('admin.clients.dynamic_notice') }}
27
38
  </div>
28
39
  @end
29
40
 
30
- <div class="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
31
- @if(clients.length === 0)
41
+ {{-- Clients estáticos (config, somente leitura). --}}
42
+ <h2 class="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">{{ t('admin.clients.static_section') }}</h2>
43
+ <div class="mb-8 overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
44
+ @if(staticClients.length === 0)
32
45
  <p class="p-6 text-sm text-gray-500">{{ t('admin.clients.empty') }}</p>
33
46
  @else
34
- @each(client in clients)
47
+ @each(client in staticClients)
35
48
  <div class="border-b border-gray-100 p-4 last:border-0">
36
49
  <div class="flex items-center justify-between">
37
50
  <p class="text-sm font-medium text-gray-900">{{ client.clientId }}</p>
@@ -47,5 +60,57 @@
47
60
  @end
48
61
  @end
49
62
  </div>
63
+
64
+ {{-- Clients dinâmicos (adapter, com CRUD). --}}
65
+ <div class="mb-2 flex items-center justify-between">
66
+ <h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500">{{ t('admin.clients.dynamic_section') }}</h2>
67
+ @if(dynamicSupported)
68
+ <a href="/admin/clients/new" class="rounded-lg bg-gray-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-gray-800">
69
+ {{ t('admin.clients.new') }}
70
+ </a>
71
+ @end
72
+ </div>
73
+
74
+ @if(!dynamicSupported)
75
+ <div class="rounded-xl bg-white p-6 text-sm text-gray-500 shadow-sm ring-1 ring-black/5">
76
+ {{ t('admin.clients.dynamic_not_supported') }}
77
+ </div>
78
+ @else
79
+ <div class="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
80
+ @if(dynamicClients.length === 0)
81
+ <p class="p-6 text-sm text-gray-500">{{ t('admin.clients.dynamic_empty') }}</p>
82
+ @else
83
+ @each(client in dynamicClients)
84
+ <div class="border-b border-gray-100 p-4 last:border-0">
85
+ <div class="flex items-center justify-between gap-2">
86
+ <p class="break-all text-sm font-medium text-gray-900">{{ client.clientId }}</p>
87
+ <span class="shrink-0 rounded-full px-2 py-0.5 text-xs {{ client.confidential ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-600' }}">
88
+ {{ client.confidential ? t('admin.clients.confidential') : t('admin.clients.public') }}
89
+ </span>
90
+ </div>
91
+ <p class="mt-1 text-xs text-gray-500">{{ t('admin.clients.grants', { grants: client.grants.join(', ') }) }}</p>
92
+ @if(client.redirectUris.length > 0)
93
+ <p class="text-xs text-gray-400">{{ t('admin.clients.redirect_uris', { uris: client.redirectUris.join(', ') }) }}</p>
94
+ @end
95
+ <div class="mt-3 flex flex-wrap items-center gap-2">
96
+ <a href="/admin/clients/{{ client.clientId }}/edit" class="rounded border border-gray-300 px-3 py-1 text-xs hover:bg-gray-50">
97
+ {{ t('admin.clients.edit') }}
98
+ </a>
99
+ @if(client.confidential)
100
+ <form method="POST" action="/admin/clients/{{ client.clientId }}/regenerate-secret" onsubmit="return confirm('{{ t('admin.clients.regenerate_confirm') }}')">
101
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
102
+ <button type="submit" class="rounded border border-gray-300 px-3 py-1 text-xs hover:bg-gray-50">{{ t('admin.clients.regenerate_secret') }}</button>
103
+ </form>
104
+ @end
105
+ <form method="POST" action="/admin/clients/{{ client.clientId }}/delete" onsubmit="return confirm('{{ t('admin.clients.delete_confirm') }}')">
106
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
107
+ <button type="submit" class="rounded border border-red-300 px-3 py-1 text-xs text-red-700 hover:bg-red-50">{{ t('admin.clients.delete') }}</button>
108
+ </form>
109
+ </div>
110
+ </div>
111
+ @end
112
+ @end
113
+ </div>
114
+ @end
50
115
  </div>
51
116
  </body></html>
@@ -0,0 +1,89 @@
1
+ <!doctype html>
2
+ <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('admin.sessions.page_title') }}</title>
3
+ <script src="https://cdn.tailwindcss.com"></script></head>
4
+ <body class="min-h-screen bg-gray-100 p-4">
5
+ <div class="mx-auto max-w-4xl">
6
+ <div class="flex items-center justify-between py-6">
7
+ <div>
8
+ <div class="text-xs font-semibold uppercase tracking-widest text-gray-400">{{ t('common.brand_eyebrow') }}</div>
9
+ <h1 class="text-xl font-semibold text-gray-900">{{ t('admin.sessions.title') }}</h1>
10
+ @if(email)
11
+ <p class="mt-1 text-sm text-gray-500">{{ t('admin.sessions.account', { email: email }) }}</p>
12
+ @end
13
+ </div>
14
+ <form method="POST" action="/account/logout">
15
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
16
+ <button type="submit" class="text-sm text-gray-500 hover:underline">{{ t('admin.nav.logout') }}</button>
17
+ </form>
18
+ </div>
19
+
20
+ <nav class="mb-6 flex gap-4 text-sm font-medium">
21
+ <a href="/admin" class="text-gray-500 hover:underline">{{ t('admin.nav.dashboard') }}</a>
22
+ <a href="/admin/users" class="text-gray-900 underline">{{ t('admin.nav.users') }}</a>
23
+ <a href="/admin/clients" class="text-gray-500 hover:underline">{{ t('admin.nav.clients') }}</a>
24
+ <a href="/admin/audit" class="text-gray-500 hover:underline">{{ t('admin.nav.audit') }}</a>
25
+ </nav>
26
+
27
+ <a href="/admin/users" class="mb-4 inline-block text-sm text-gray-500 hover:underline">&larr; {{ t('admin.sessions.back') }}</a>
28
+
29
+ @if(!supported)
30
+ <div class="rounded-xl bg-white p-6 text-sm text-gray-500 shadow-sm ring-1 ring-black/5">
31
+ {{ t('admin.sessions.not_supported') }}
32
+ </div>
33
+ @else
34
+ @if(revoked)
35
+ <div class="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4">
36
+ <p class="text-sm font-medium text-emerald-900">
37
+ {{ t('admin.sessions.revoked_notice', { sessions: revoked.sessions, grants: revoked.grants, accessTokens: revoked.accessTokens, refreshTokens: revoked.refreshTokens }) }}
38
+ </p>
39
+ </div>
40
+ @end
41
+
42
+ <h2 class="mb-2 text-sm font-semibold text-gray-700">{{ t('admin.sessions.sessions_section') }}</h2>
43
+ <div class="mb-6 overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
44
+ @if(sessions.length === 0)
45
+ <p class="p-6 text-sm text-gray-500">{{ t('admin.sessions.sessions_empty') }}</p>
46
+ @else
47
+ @each(session in sessions)
48
+ <div class="border-b border-gray-100 p-4 last:border-0">
49
+ <p class="text-sm font-medium text-gray-900">{{ session.id }}</p>
50
+ @if(session.loginTs)
51
+ <p class="text-xs text-gray-500">{{ t('admin.sessions.session_login_ts', { date: session.loginTs }) }}</p>
52
+ @end
53
+ @if(session.amr)
54
+ <p class="text-xs text-gray-400">{{ t('admin.sessions.session_amr', { amr: session.amr }) }}</p>
55
+ @end
56
+ </div>
57
+ @end
58
+ @end
59
+ </div>
60
+
61
+ <h2 class="mb-2 text-sm font-semibold text-gray-700">{{ t('admin.sessions.grants_section') }}</h2>
62
+ <div class="mb-6 overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
63
+ @if(grants.length === 0)
64
+ <p class="p-6 text-sm text-gray-500">{{ t('admin.sessions.grants_empty') }}</p>
65
+ @else
66
+ @each(grant in grants)
67
+ <div class="border-b border-gray-100 p-4 last:border-0">
68
+ <p class="text-sm font-medium text-gray-900">{{ grant.id }}</p>
69
+ @if(grant.clientId)
70
+ <p class="text-xs text-gray-500">{{ t('admin.sessions.grant_client', { clientId: grant.clientId }) }}</p>
71
+ @end
72
+ <p class="text-xs text-gray-400">{{ t('admin.sessions.grant_tokens', { accessTokens: grant.accessTokens, refreshTokens: grant.refreshTokens }) }}</p>
73
+ </div>
74
+ @end
75
+ @end
76
+ </div>
77
+
78
+ @if(sessions.length > 0 || grants.length > 0)
79
+ <form method="POST" action="/admin/users/{{ accountId }}/revoke-sessions"
80
+ onsubmit="return confirm('{{ t('admin.sessions.revoke_confirm') }}')">
81
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
82
+ <button type="submit" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:opacity-90">
83
+ {{ t('admin.sessions.revoke_all') }}
84
+ </button>
85
+ </form>
86
+ @end
87
+ @end
88
+ </div>
89
+ </body></html>
@@ -42,6 +42,7 @@
42
42
  <p class="text-xs text-gray-500">{{ user.name }}</p>
43
43
  @end
44
44
  </div>
45
+ <a href="/admin/users/{{ user.id }}/sessions" class="text-sm text-gray-500 hover:underline">{{ t('admin.users.sessions') }}</a>
45
46
  </div>
46
47
  <form method="POST" action="/admin/users/{{ user.id }}/roles" class="mt-3 flex gap-2">
47
48
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">