@adonis-agora/authkit-server 0.50.0 → 0.51.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.
@@ -190,14 +190,19 @@
190
190
  @end
191
191
  @end
192
192
 
193
- {{-- Passwordless: confirmação de magic link enviado (anti-enumeração). --}}
193
+ {{-- Passwordless: confirmação de magic link enviado (anti-enumeração).
194
+ `magicChannel` (choose-first) escolhe a sub-view: 'code' = só o campo de
195
+ código, 'link' = só o aviso de link, 'both'/ausente = ambos (histórico). --}}
194
196
  @if(magicLinkSent)
195
- <p class="mt-4 rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">{{ t('login.magic_link_sent') }}</p>
197
+ @if(magicChannel !== 'code')
198
+ <p class="mt-4 rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">{{ t('login.magic_link_sent') }}</p>
199
+ @end
196
200
 
197
201
  {{-- Login por OTP: campo de código digitável (mesmo e-mail carrega link E código). --}}
198
- @if(otpEnabled)
202
+ @if(otpEnabled && magicChannel !== 'link')
199
203
  <form method="POST" action="/auth/interaction/{{ uid }}/otp-verify" class="mt-4">
200
204
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
205
+ <input type="hidden" name="channel" value="code">
201
206
  <label for="otp-code" class="block text-sm font-medium text-gray-700">{{ t('login.otp_label') }}</label>
202
207
  <input id="otp-code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code"
203
208
  pattern="[0-9]*" placeholder="{{ t('login.otp_placeholder') }}"
@@ -47,6 +47,13 @@ export interface MailHooks {
47
47
  * fluxo só-magic-link (back-compat).
48
48
  */
49
49
  code?: string;
50
+ /**
51
+ * Canal escolhido no seletor "choose-first": `'code'` = o host deveria
52
+ * renderizar SÓ o código, `'link'` = SÓ o link. Ausente = ambos (histórico).
53
+ * Puramente de superfície — os dois tokens continuam emitidos co-locados;
54
+ * hosts existentes simplesmente ignoram este campo (back-compat).
55
+ */
56
+ channel?: 'code' | 'link';
50
57
  }) => Promise<void>;
51
58
  /**
52
59
  * Envia o link de CONFIRMAÇÃO DE IDENTIDADE (sudo). Distinto de
@@ -7,6 +7,7 @@ import { sendMagicLinkEmail } from '../default_mailer.js';
7
7
  import { sendOtpUnlockEmail } from '../default_mailer.js';
8
8
  import { translate } from '../i18n.js';
9
9
  import { attemptPasswordLogin, isEmailUnverifiedBlock } from '../login_attempt.js';
10
+ import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
10
11
  import { notifyLoginSuccess } from '../login_notify.js';
11
12
  import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
12
13
  import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
@@ -39,17 +40,31 @@ export default class AuthInteractionController {
39
40
  * passo login. Sem isto, os re-renders (erro de senha, magic link enviado, lockout…) mandam
40
41
  * `authMethods` undefined → a view volta ao default (senha ligada), ignorando a config.
41
42
  * `passkeyFirst` depende da conta (resolvido no fluxo principal); aqui cobrimos senha + magic link.
43
+ *
44
+ * `otpEnabled` (disponibilidade do login por OTP: config ligada E store com a
45
+ * capacidade) também sai daqui — é um FATO de disponibilidade de método de login,
46
+ * como `magicLinkAvailable`. Assim TODO render do passo login (identifier, seletor
47
+ * choose-first, magicLinkSent, erro) carrega a flag POR CONSTRUÇÃO: o seletor
48
+ * precisa dela para oferecer a opção "código" ANTES do envio do magic link.
49
+ *
50
+ * `runtimeSettings` pode ser passado já resolvido (ex.: o `show()` já resolveu para
51
+ * maintenance) para evitar uma segunda resolução; ausente, resolve sob demanda.
42
52
  */
43
- async #loginMethods(ctx, cfg) {
44
- const runtimeSettings = await getRuntimeSettings(ctx);
53
+ async #loginMethods(ctx, cfg, runtimeSettings) {
54
+ const settings = runtimeSettings ?? (await getRuntimeSettings(ctx));
45
55
  const magicLinkCapableConfig = cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore);
46
- const authMethods = await resolveEffectiveAuthMethods(runtimeSettings, {
56
+ const authMethods = await resolveEffectiveAuthMethods(settings, {
47
57
  configuredSocialProviders: cfg.social?.providers ?? [],
48
58
  magicLinkCapable: magicLinkCapableConfig,
49
59
  passkeyCapable: false,
50
60
  configOverrides: cfg.authMethods,
51
61
  });
52
- return { authMethods, magicLinkAvailable: authMethods.magicLink && magicLinkCapableConfig };
62
+ const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
63
+ return {
64
+ authMethods,
65
+ magicLinkAvailable: authMethods.magicLink && magicLinkCapableConfig,
66
+ otpEnabled,
67
+ };
53
68
  }
54
69
  async show(ctx) {
55
70
  const service = await ctx.containerResolver.make('authkit.server');
@@ -101,36 +116,32 @@ export default class AuthInteractionController {
101
116
  });
102
117
  }
103
118
  const email = ctx.session.get(SESSION_KEY);
104
- // Resolve auth methods (capabilities: magic link, passkey-first, social providers).
105
- // Capabilities are account-independent at this stage for the identifier step.
106
- const magicLinkCapableConfig = cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore);
107
- const configuredSocialProviders = cfg.social?.providers ?? [];
108
- const authMethodsSettings = runtimeSettingsForMaintenance;
109
- const authMethods = await resolveEffectiveAuthMethods(authMethodsSettings, {
110
- configuredSocialProviders,
111
- magicLinkCapable: magicLinkCapableConfig,
112
- passkeyCapable: false, // passkey-first depends on account — resolved in step 2
113
- configOverrides: cfg.authMethods,
114
- });
119
+ // Métodos de login efetivos (magic link, OTP, social) — account-independent
120
+ // neste ponto (passkey-first depende da conta, resolvido no step 2). Reusa o
121
+ // helper compartilhado por TODOS os renders do passo login, para que
122
+ // `authMethods`, `magicLinkAvailable` e `otpEnabled` saiam POR CONSTRUÇÃO
123
+ // (o seletor choose-first precisa de `otpEnabled` para oferecer "código").
124
+ // Passa o runtimeSettings já resolvido para maintenance (sem 2ª resolução).
125
+ const loginMethods = await this.#loginMethods(ctx, cfg, runtimeSettingsForMaintenance);
126
+ const authMethods = loginMethods.authMethods;
115
127
  if (!email) {
116
128
  // Step 1: identifier (email only).
117
129
  // Resolve registration enabled to hide "create account" link when closed.
118
130
  const registrationEnabled = await resolveEffectiveRegistration(cfg.registration?.enabled ?? true, runtimeSettingsForMaintenance);
119
131
  return render(ctx, 'login', {
132
+ ...loginMethods,
120
133
  uid: details.uid,
121
134
  csrfToken: ctx.request.csrfToken,
122
135
  step: 'identifier',
123
136
  registrationEnabled,
124
137
  brand,
125
- authMethods,
126
138
  });
127
139
  }
128
140
  // Step 2: password — look up user for personalisation (enumeration-safe: always show step 2)
129
141
  const acc = await cfg.accountStore.findByEmail(email);
130
142
  const account = acc ? { fullName: acc.name ?? null, globalRoles: acc.globalRoles ?? [] } : null;
131
- // Passwordless: magic link disponível se ligado E o store suporta E auth_methods permite.
143
+ // Passwordless: magic link disponível vem do helper (`loginMethods`).
132
144
  // Passkey-first disponível se ligado, o store suporta, a conta tem passkeys E auth_methods permite.
133
- const magicLinkAvailable = authMethods.magicLink && magicLinkCapableConfig;
134
145
  const passkeyFirstCapable = cfg.passwordless.passkeyFirst && !!acc && (await this.hasPasskeys(cfg, acc.id));
135
146
  const passkeyFirstAvailable = authMethods.passkey && passkeyFirstCapable;
136
147
  // Effective bot protection: may be overridden at runtime via auth_settings.
@@ -138,16 +149,15 @@ export default class AuthInteractionController {
138
149
  // Session policy: resolve para exibir o checkbox "manter conectado".
139
150
  const sessionPolicyForShow = await resolveEffectiveSessionPolicy(runtimeSettingsForMaintenance, cfg.ttl?.session ? Math.ceil(cfg.ttl.session / 3600) : undefined);
140
151
  return render(ctx, 'login', {
152
+ ...loginMethods,
141
153
  uid: details.uid,
142
154
  csrfToken: ctx.request.csrfToken,
143
155
  step: 'password',
144
156
  email,
145
157
  account,
146
158
  brand,
147
- magicLinkAvailable,
148
159
  passkeyFirstAvailable,
149
160
  botProtection: effectiveBot?.on.includes('login') ? effectiveBot.widget : undefined,
150
- authMethods,
151
161
  rememberEnabled: sessionPolicyForShow.rememberEnabled,
152
162
  rememberDays: sessionPolicyForShow.rememberDays,
153
163
  });
@@ -612,6 +622,10 @@ export default class AuthInteractionController {
612
622
  // Login por OTP: liga o campo de código na tela "link enviado" quando a config
613
623
  // está ligada E o store suporta a capacidade.
614
624
  const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
625
+ // Seletor "choose-first": o host pode POSTar `channel=code|link` para pedir que
626
+ // o e-mail e a tela mostrem SÓ aquele método. Ausente/ inválido = both (histórico).
627
+ // NÃO condiciona a emissão de token — os dois continuam saindo co-locados.
628
+ const channel = normalizeLoginChannel(ctx.request.input('channel'));
615
629
  if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
616
630
  const ip = ctx.request.ip?.() ?? null;
617
631
  const clientId = details.params.client_id ?? null;
@@ -644,14 +658,16 @@ export default class AuthInteractionController {
644
658
  const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
645
659
  const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
646
660
  if (cfg.mail?.onMagicLink) {
647
- await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code });
661
+ await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code, channel });
648
662
  }
649
663
  else {
650
- await sendMagicLinkEmail(ctx, { email, magicUrl, code });
664
+ await sendMagicLinkEmail(ctx, { email, magicUrl, code, channel });
651
665
  }
652
666
  }
653
667
  }
654
668
  // Resposta uniforme (não vaza existência de conta).
669
+ // `otpEnabled` vem do spread de `#loginMethods` (mesmo valor do `otpEnabled`
670
+ // local usado acima para decidir a emissão) — não re-declarado aqui.
655
671
  return render(ctx, 'login', {
656
672
  ...(await this.#loginMethods(ctx, cfg)),
657
673
  uid,
@@ -661,7 +677,10 @@ export default class AuthInteractionController {
661
677
  account: null,
662
678
  brand,
663
679
  magicLinkSent: true,
664
- otpEnabled,
680
+ // Prop da tela: qual sub-view do estado `magicLinkSent` mostrar —
681
+ // 'code' (só o campo de código), 'link' (só o aviso de link) ou 'both'
682
+ // (ambos, quando o host não escolheu canal). Back-compat: ausente = 'both'.
683
+ magicChannel: magicChannelProp(channel),
665
684
  });
666
685
  }
667
686
  /**
@@ -748,10 +767,15 @@ export default class AuthInteractionController {
748
767
  }
749
768
  const code = String(ctx.request.input('code', '') ?? '').trim();
750
769
  const brand = brandFor(cfg.branding, clientId ?? undefined, undefined);
770
+ // Mantém a sub-view do seletor no re-render de erro (o form de código pode
771
+ // POSTar `channel=code`). Ausente = both (histórico).
772
+ const channel = normalizeLoginChannel(ctx.request.input('channel'));
751
773
  const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
752
774
  maxAttempts: cfg.login.otp.maxAttempts,
753
775
  });
754
776
  // Re-render da tela "link enviado" com o campo de código + erro localizado.
777
+ // `otpEnabled` vem do spread de `#loginMethods` (aqui, após a guarda acima,
778
+ // vale `true`) — não re-declarado inline.
755
779
  const renderOtpError = async (messageKey) => render(ctx, 'login', {
756
780
  ...(await this.#loginMethods(ctx, cfg)),
757
781
  uid,
@@ -761,7 +785,7 @@ export default class AuthInteractionController {
761
785
  account: null,
762
786
  brand,
763
787
  magicLinkSent: true,
764
- otpEnabled: true,
788
+ magicChannel: magicChannelProp(channel),
765
789
  otpError: translate(cfg.messages, messageKey),
766
790
  });
767
791
  if (result.status === 'ok') {
@@ -63,6 +63,7 @@ export declare function sendMagicLinkEmail(ctx: HttpContext, data: {
63
63
  email: string;
64
64
  magicUrl: string;
65
65
  code?: string;
66
+ channel?: 'code' | 'link';
66
67
  }): Promise<void>;
67
68
  /**
68
69
  * Envia o e-mail de aviso de segurança ao e-mail ATUAL quando uma troca de
@@ -249,20 +249,41 @@ export async function sendMagicLinkEmail(ctx, data) {
249
249
  try {
250
250
  const brand = resolveBrand(ctx);
251
251
  const { messages: t, locale } = resolveMailMessages(ctx);
252
- const content = renderTransactionalEmail({
253
- brand,
254
- locale,
255
- linkFallback: translate(t, 'mail.common.link_fallback'),
256
- subject: translate(t, 'mail.magic_link.subject'),
257
- heading: translate(t, 'mail.magic_link.heading'),
258
- intro: translate(t, 'mail.magic_link.intro'),
259
- ctaLabel: translate(t, 'mail.magic_link.cta'),
260
- ctaUrl: data.magicUrl,
261
- footnote: translate(t, 'mail.magic_link.fallback'),
262
- // Login por OTP: quando o código é fornecido, renderiza-o em destaque.
263
- code: data.code,
264
- codeLabel: translate(t, 'mail.magic_link.code_label'),
265
- });
252
+ // Login choose-first: o `channel` decide o que o e-mail SURFA (os dois tokens
253
+ // continuam emitidos co-locados a montante — isto é só renderização).
254
+ // - 'code' → e-mail SÓ com o código (sem botão/link), quando há código;
255
+ // - 'link' → e-mail SÓ com o link (código suprimido);
256
+ // - ausente → ambos (comportamento histórico — back-compat).
257
+ // Degradação limpa: `channel: 'code'` sem código emitido (OTP desligado) cai
258
+ // no e-mail de link — não dá pra mostrar um código inexistente.
259
+ const codeOnly = data.channel === 'code' && !!data.code;
260
+ const linkOnly = data.channel === 'link';
261
+ const content = codeOnly
262
+ ? renderTransactionalEmail({
263
+ brand,
264
+ locale,
265
+ subject: translate(t, 'mail.magic_link.code_subject'),
266
+ heading: translate(t, 'mail.magic_link.heading'),
267
+ intro: translate(t, 'mail.magic_link.code_intro'),
268
+ footnote: translate(t, 'mail.magic_link.fallback'),
269
+ // Sem `ctaUrl`: e-mail sem botão nem fallback de link — só o código.
270
+ code: data.code,
271
+ codeLabel: translate(t, 'mail.magic_link.code_only_label'),
272
+ })
273
+ : renderTransactionalEmail({
274
+ brand,
275
+ locale,
276
+ linkFallback: translate(t, 'mail.common.link_fallback'),
277
+ subject: translate(t, 'mail.magic_link.subject'),
278
+ heading: translate(t, 'mail.magic_link.heading'),
279
+ intro: translate(t, 'mail.magic_link.intro'),
280
+ ctaLabel: translate(t, 'mail.magic_link.cta'),
281
+ ctaUrl: data.magicUrl,
282
+ footnote: translate(t, 'mail.magic_link.fallback'),
283
+ // Código em destaque quando presente — suprimido no canal 'link'.
284
+ code: linkOnly ? undefined : data.code,
285
+ codeLabel: translate(t, 'mail.magic_link.code_label'),
286
+ });
266
287
  const sent = await sendEmail(ctx, data.email, content);
267
288
  if (!sent) {
268
289
  ctx.logger.info({ magicUrl: data.magicUrl, email: data.email }, 'authkit: magic link de login (dev — @adonisjs/mail ausente)');
@@ -24,10 +24,17 @@ interface EmailTemplateInput {
24
24
  heading: string;
25
25
  /** Parágrafo de introdução (texto puro, será escapado). */
26
26
  intro: string;
27
- /** Rótulo do botão de CTA. */
28
- ctaLabel: string;
29
- /** URL do CTA. */
30
- ctaUrl: string;
27
+ /**
28
+ * Rótulo do botão de CTA. Opcional junto de `ctaUrl`: e-mails "só código"
29
+ * (login por OTP com `channel: 'code'`) não têm botão nem link.
30
+ */
31
+ ctaLabel?: string;
32
+ /**
33
+ * URL do CTA. Quando ausente, o e-mail é renderizado SEM botão e SEM o
34
+ * fallback de link (usado no e-mail "só código"). Todos os callers históricos
35
+ * passam este campo — byte-parity preservado.
36
+ */
37
+ ctaUrl?: string;
31
38
  /** Linha auxiliar abaixo do botão (ex.: validade do link). */
32
39
  footnote?: string;
33
40
  /** Texto que precede o link de fallback (i18n). Default em inglês. */
@@ -51,11 +51,13 @@ export function renderTransactionalEmail(input) {
51
51
  <tr><td style="padding:32px 28px 8px;">
52
52
  <h1 style="margin:0 0 12px;font-size:20px;line-height:1.3;color:#111827;">${esc(input.heading)}</h1>
53
53
  <p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#374151;">${esc(input.intro)}</p>
54
- ${codeBlock}<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:8px;background:${esc(accent)};">
55
- <a href="${esc(input.ctaUrl)}" style="display:inline-block;padding:12px 24px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">${esc(input.ctaLabel)}</a>
56
- </td></tr></table>
54
+ ${codeBlock}${input.ctaUrl
55
+ ? `<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:8px;background:${esc(accent)};">
56
+ <a href="${esc(input.ctaUrl)}" style="display:inline-block;padding:12px 24px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">${esc(input.ctaLabel ?? '')}</a>
57
+ </td></tr></table>`
58
+ : ''}
57
59
  ${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(input.footnote)}</p>` : ''}
58
- <p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(linkFallback)}<br><a href="${esc(input.ctaUrl)}" style="color:${esc(accent)};word-break:break-all;">${esc(input.ctaUrl)}</a></p>
60
+ ${input.ctaUrl ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(linkFallback)}<br><a href="${esc(input.ctaUrl)}" style="color:${esc(accent)};word-break:break-all;">${esc(input.ctaUrl)}</a></p>` : ''}
59
61
  </td></tr>
60
62
  <tr><td style="padding:24px 28px 28px;border-top:1px solid #f3f4f6;">
61
63
  <p style="margin:0;font-size:12px;line-height:1.5;color:#9ca3af;">${esc(company)} ${year}</p>
@@ -70,8 +72,7 @@ ${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;col
70
72
  '',
71
73
  input.intro,
72
74
  ...(input.code ? ['', `${codeLabel} ${input.code}`] : []),
73
- '',
74
- `${input.ctaLabel}: ${input.ctaUrl}`,
75
+ ...(input.ctaUrl ? ['', `${input.ctaLabel}: ${input.ctaUrl}`] : []),
75
76
  ...(input.footnote ? ['', input.footnote] : []),
76
77
  '',
77
78
  `— ${company}`,
@@ -518,6 +518,9 @@ export declare const DEFAULT_MESSAGES: {
518
518
  'mail.magic_link.cta': string;
519
519
  'mail.magic_link.fallback': string;
520
520
  'mail.magic_link.code_label': string;
521
+ 'mail.magic_link.code_subject': string;
522
+ 'mail.magic_link.code_intro': string;
523
+ 'mail.magic_link.code_only_label': string;
521
524
  'mail.new_login.subject': string;
522
525
  'mail.new_login.heading': string;
523
526
  'mail.new_login.intro': string;
@@ -1219,6 +1222,9 @@ export declare const PT_BR_MESSAGES: {
1219
1222
  'mail.magic_link.cta': string;
1220
1223
  'mail.magic_link.fallback': string;
1221
1224
  'mail.magic_link.code_label': string;
1225
+ 'mail.magic_link.code_subject': string;
1226
+ 'mail.magic_link.code_intro': string;
1227
+ 'mail.magic_link.code_only_label': string;
1222
1228
  'mail.new_login.subject': string;
1223
1229
  'mail.new_login.heading': string;
1224
1230
  'mail.new_login.intro': string;
@@ -558,6 +558,10 @@ export const DEFAULT_MESSAGES = {
558
558
  'mail.magic_link.cta': 'Sign in',
559
559
  'mail.magic_link.fallback': 'If you did not request this, you can ignore this email.',
560
560
  'mail.magic_link.code_label': 'Or enter this code to sign in:',
561
+ // E-mail "só código" (login choose-first com channel: 'code'): sem botão/link.
562
+ 'mail.magic_link.code_subject': 'Your login code',
563
+ 'mail.magic_link.code_intro': 'Use the code below to sign in. It expires shortly and can be used once.',
564
+ 'mail.magic_link.code_only_label': 'Enter this code to sign in:',
561
565
  'mail.new_login.subject': 'New login to your account',
562
566
  'mail.new_login.heading': 'New login detected',
563
567
  'mail.new_login.intro': 'We detected a new login to your account.',
@@ -1335,6 +1339,10 @@ export const PT_BR_MESSAGES = {
1335
1339
  'mail.magic_link.cta': 'Entrar',
1336
1340
  'mail.magic_link.fallback': 'Se você não solicitou isso, pode ignorar este e-mail.',
1337
1341
  'mail.magic_link.code_label': 'Ou digite este código para entrar:',
1342
+ // E-mail "só código" (login choose-first com channel: 'code'): sem botão/link.
1343
+ 'mail.magic_link.code_subject': 'Seu código de login',
1344
+ 'mail.magic_link.code_intro': 'Use o código abaixo para entrar. Ele expira em instantes e serve para um único acesso.',
1345
+ 'mail.magic_link.code_only_label': 'Digite este código para entrar:',
1338
1346
  'mail.new_login.subject': 'Novo login na sua conta',
1339
1347
  'mail.new_login.heading': 'Novo login detectado',
1340
1348
  'mail.new_login.intro': 'Detectamos um novo login na sua conta.',
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Canal de login escolhido no seletor "choose-first" (modelo GitHub): o usuário
3
+ * decide PRIMEIRO como quer entrar e só então o método é executado.
4
+ *
5
+ * IMPORTANTE — o `channel` é puramente de SUPERFÍCIE: NÃO condiciona a emissão de
6
+ * token nem toca no codec `ml2:` / lockout / single-use-conjunto. A lib continua
7
+ * emitindo link E código co-locados (quando `login.otp.enabled`); o `channel` só
8
+ * decide o que o E-MAIL renderiza e qual sub-view a TELA mostra no estado
9
+ * `magicLinkSent`. Ausente/ inválido = comportamento histórico ("both").
10
+ */
11
+ /** Método escolhido no seletor. `passkey` é slot documentado (ainda não emitido). */
12
+ export type LoginChannel = 'code' | 'link';
13
+ /**
14
+ * Valor da prop de render que a tela usa para escolher a sub-view do estado
15
+ * `magicLinkSent`: `'code'` = só campo de código, `'link'` = só aviso de link,
16
+ * `'both'` = ambos (comportamento histórico, quando o host não manda `channel`).
17
+ */
18
+ export type MagicChannelProp = 'code' | 'link' | 'both';
19
+ /**
20
+ * Lê e valida o campo `channel` do body do POST `/magic`. Só `'code'` e `'link'`
21
+ * são aceitos; qualquer outro valor (ausente, vazio, lixo) vira `undefined`, que
22
+ * a lib trata como "both" — garantindo back-compat total com hosts que ainda
23
+ * POSTam sem o campo.
24
+ */
25
+ export declare function normalizeLoginChannel(raw: unknown): LoginChannel | undefined;
26
+ /**
27
+ * Mapeia o `channel` do body para a prop de render `magicChannel`. Ausente
28
+ * (`undefined`) → `'both'`: a tela mostra as duas sub-views, como hoje.
29
+ */
30
+ export declare function magicChannelProp(channel: LoginChannel | undefined): MagicChannelProp;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Canal de login escolhido no seletor "choose-first" (modelo GitHub): o usuário
3
+ * decide PRIMEIRO como quer entrar e só então o método é executado.
4
+ *
5
+ * IMPORTANTE — o `channel` é puramente de SUPERFÍCIE: NÃO condiciona a emissão de
6
+ * token nem toca no codec `ml2:` / lockout / single-use-conjunto. A lib continua
7
+ * emitindo link E código co-locados (quando `login.otp.enabled`); o `channel` só
8
+ * decide o que o E-MAIL renderiza e qual sub-view a TELA mostra no estado
9
+ * `magicLinkSent`. Ausente/ inválido = comportamento histórico ("both").
10
+ */
11
+ /**
12
+ * Lê e valida o campo `channel` do body do POST `/magic`. Só `'code'` e `'link'`
13
+ * são aceitos; qualquer outro valor (ausente, vazio, lixo) vira `undefined`, que
14
+ * a lib trata como "both" — garantindo back-compat total com hosts que ainda
15
+ * POSTam sem o campo.
16
+ */
17
+ export function normalizeLoginChannel(raw) {
18
+ return raw === 'code' || raw === 'link' ? raw : undefined;
19
+ }
20
+ /**
21
+ * Mapeia o `channel` do body para a prop de render `magicChannel`. Ausente
22
+ * (`undefined`) → `'both'`: a tela mostra as duas sub-views, como hoje.
23
+ */
24
+ export function magicChannelProp(channel) {
25
+ return channel ?? 'both';
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.50.0",
3
+ "version": "0.51.1",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",