@dudousxd/adonis-authkit-server 0.4.0 → 0.6.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.
- package/README.md +23 -2
- package/build/host/views/account/apps.edge +58 -0
- package/build/host/views/account/security.edge +53 -0
- package/build/host/views/account/tokens.edge +1 -0
- package/build/host/views/admin/users.edge +62 -2
- package/build/host/views/login.edge +55 -0
- package/build/host/views/mfa-challenge.edge +12 -0
- package/build/index.d.ts +9 -3
- package/build/index.js +5 -2
- package/build/src/accounts/account_store.d.ts +80 -2
- package/build/src/accounts/account_store.js +12 -0
- package/build/src/accounts/lucid_account_store.js +8 -0
- package/build/src/accounts/lucid_store/core.d.ts +2 -2
- package/build/src/accounts/lucid_store/core.js +33 -0
- package/build/src/accounts/lucid_store/mfa.js +4 -1
- package/build/src/accounts/lucid_store/status_profile.d.ts +21 -0
- package/build/src/accounts/lucid_store/status_profile.js +66 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/define_config.d.ts +53 -0
- package/build/src/define_config.js +14 -1
- package/build/src/doctor/checks.js +32 -32
- package/build/src/events/dispatcher.d.ts +45 -0
- package/build/src/events/dispatcher.js +92 -0
- package/build/src/host/admin_sessions_service.d.ts +8 -0
- package/build/src/host/admin_sessions_service.js +19 -0
- package/build/src/host/controllers/account_apps_controller.d.ts +15 -0
- package/build/src/host/controllers/account_apps_controller.js +61 -0
- package/build/src/host/controllers/account_mfa_controller.js +6 -2
- package/build/src/host/controllers/account_security_controller.d.ts +9 -0
- package/build/src/host/controllers/account_security_controller.js +52 -2
- package/build/src/host/controllers/account_session_controller.js +3 -1
- package/build/src/host/controllers/admin/admin_users_controller.d.ts +13 -0
- package/build/src/host/controllers/admin/admin_users_controller.js +133 -0
- package/build/src/host/controllers/interaction_controller.d.ts +32 -0
- package/build/src/host/controllers/interaction_controller.js +175 -8
- package/build/src/host/default_mailer.d.ts +8 -0
- package/build/src/host/default_mailer.js +81 -19
- package/build/src/host/email_templates.d.ts +4 -0
- package/build/src/host/email_templates.js +5 -2
- package/build/src/host/i18n.d.ts +395 -11
- package/build/src/host/i18n.js +433 -12
- package/build/src/host/login_attempt.d.ts +1 -0
- package/build/src/host/login_attempt.js +11 -0
- package/build/src/host/register_auth_host.js +18 -1
- package/build/src/host/trusted_device.d.ts +61 -0
- package/build/src/host/trusted_device.js +65 -0
- package/build/src/host/validators.d.ts +35 -0
- package/build/src/host/validators.js +14 -0
- package/build/src/observability/metrics_controller.js +4 -4
- package/package.json +1 -1
|
@@ -3,7 +3,9 @@ import { brandFor, isFirstParty } from '../branding.js';
|
|
|
3
3
|
import { translate } from '../i18n.js';
|
|
4
4
|
import { attemptPasswordLogin } from '../login_attempt.js';
|
|
5
5
|
import { notifyLoginSuccess } from '../login_notify.js';
|
|
6
|
-
import { supportsPasskeys } from '../../accounts/account_store.js';
|
|
6
|
+
import { supportsPasskeys, supportsMagicLink } from '../../accounts/account_store.js';
|
|
7
|
+
import { sendMagicLinkEmail } from '../default_mailer.js';
|
|
8
|
+
import { TRUSTED_DEVICE_COOKIE, buildTrustedDevicePayload, isTrustedDeviceValid, } from '../trusted_device.js';
|
|
7
9
|
const SESSION_KEY = 'authkit_login_email';
|
|
8
10
|
/** accountId aguardando o 2º fator depois da senha verificada. */
|
|
9
11
|
const MFA_PENDING_KEY = 'authkit_mfa_pending';
|
|
@@ -45,6 +47,10 @@ export default class AuthInteractionController {
|
|
|
45
47
|
// Step 2: password — look up user for personalisation (enumeration-safe: always show step 2)
|
|
46
48
|
const acc = await cfg.accountStore.findByEmail(email);
|
|
47
49
|
const account = acc ? { fullName: acc.name ?? null, globalRoles: acc.globalRoles ?? [] } : null;
|
|
50
|
+
// Passwordless: magic link disponível se ligado E o store suporta. Passkey-first
|
|
51
|
+
// disponível se ligado, o store suporta E a conta tem ao menos uma passkey.
|
|
52
|
+
const magicLinkAvailable = cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore);
|
|
53
|
+
const passkeyFirstAvailable = cfg.passwordless.passkeyFirst && !!acc && (await this.hasPasskeys(cfg, acc.id));
|
|
48
54
|
return render(ctx, 'login', {
|
|
49
55
|
uid: details.uid,
|
|
50
56
|
csrfToken: ctx.request.csrfToken,
|
|
@@ -52,6 +58,8 @@ export default class AuthInteractionController {
|
|
|
52
58
|
email,
|
|
53
59
|
account,
|
|
54
60
|
brand,
|
|
61
|
+
magicLinkAvailable,
|
|
62
|
+
passkeyFirstAvailable,
|
|
55
63
|
});
|
|
56
64
|
}
|
|
57
65
|
/**
|
|
@@ -102,7 +110,9 @@ export default class AuthInteractionController {
|
|
|
102
110
|
? translate(cfg.messages, 'errors.account_locked', {
|
|
103
111
|
seconds: result.retryAfterSec ?? 0,
|
|
104
112
|
})
|
|
105
|
-
:
|
|
113
|
+
: result.disabled
|
|
114
|
+
? translate(cfg.messages, 'errors.account_disabled')
|
|
115
|
+
: translate(cfg.messages, 'errors.invalid_credentials'),
|
|
106
116
|
brand,
|
|
107
117
|
});
|
|
108
118
|
}
|
|
@@ -126,6 +136,18 @@ export default class AuthInteractionController {
|
|
|
126
136
|
noEnrollment: true,
|
|
127
137
|
});
|
|
128
138
|
}
|
|
139
|
+
// Trusted device: se o mecanismo está ligado, a conta JÁ tem MFA enrolado e
|
|
140
|
+
// o request NÃO é um step-up (que sempre força o MFA), um cookie de confiança
|
|
141
|
+
// válido para ESTA conta pula o 2º fator. amr fica `['pwd']` (sem acr de MFA).
|
|
142
|
+
if (cfg.trustedDevices.enabled && mfa.enabled && !mfaRequired) {
|
|
143
|
+
const trusted = await this.checkTrustedDevice(ctx, acc.id, mfa.enabledAt ?? null);
|
|
144
|
+
if (trusted) {
|
|
145
|
+
await service.interactions.completeLogin(ctx, acc.id, { amr: ['pwd'] });
|
|
146
|
+
await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip, clientId });
|
|
147
|
+
ctx.session.forget(SESSION_KEY);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
129
151
|
ctx.session.put(MFA_PENDING_KEY, acc.id);
|
|
130
152
|
// Passkey disponível como alternativa ao TOTP se o store suporta E a conta
|
|
131
153
|
// tem ao menos uma credencial registrada.
|
|
@@ -135,6 +157,8 @@ export default class AuthInteractionController {
|
|
|
135
157
|
csrfToken: ctx.request.csrfToken,
|
|
136
158
|
brand,
|
|
137
159
|
passkeyAvailable,
|
|
160
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
161
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
138
162
|
});
|
|
139
163
|
}
|
|
140
164
|
// Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
|
|
@@ -185,9 +209,13 @@ export default class AuthInteractionController {
|
|
|
185
209
|
error: translate(cfg.messages, 'errors.invalid_code'),
|
|
186
210
|
brand,
|
|
187
211
|
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
212
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
213
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
188
214
|
});
|
|
189
215
|
}
|
|
190
|
-
// Sucesso no 2º fator:
|
|
216
|
+
// Sucesso no 2º fator: opcionalmente confia neste dispositivo (checkbox).
|
|
217
|
+
await this.maybeTrustDevice(ctx, cfg, accountId);
|
|
218
|
+
// Finaliza a interaction para o accountId pendente.
|
|
191
219
|
ctx.session.forget(MFA_PENDING_KEY);
|
|
192
220
|
ctx.session.forget(SESSION_KEY);
|
|
193
221
|
await notifyLoginSuccess(ctx, cfg, {
|
|
@@ -230,6 +258,135 @@ export default class AuthInteractionController {
|
|
|
230
258
|
const list = await cfg.accountStore.listPasskeys(accountId);
|
|
231
259
|
return Array.isArray(list) && list.length > 0;
|
|
232
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Lê o cookie de dispositivo confiável (encriptado, appKey-backed) e valida que
|
|
263
|
+
* pertence a `accountId`, não expirou e é posterior ao último (re)enrollment de
|
|
264
|
+
* MFA. Step-up NÃO chama isto (força sempre o MFA). Best-effort: qualquer erro de
|
|
265
|
+
* leitura → não confiável.
|
|
266
|
+
*/
|
|
267
|
+
async checkTrustedDevice(ctx, accountId, mfaEnabledAt) {
|
|
268
|
+
try {
|
|
269
|
+
const payload = ctx.request.encryptedCookie(TRUSTED_DEVICE_COOKIE);
|
|
270
|
+
return isTrustedDeviceValid(payload, { accountId, mfaEnabledAt });
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Se o checkbox "confiar neste dispositivo" foi marcado E o mecanismo está ligado,
|
|
278
|
+
* grava o cookie encriptado de confiança para a conta (skip MFA por N dias).
|
|
279
|
+
*/
|
|
280
|
+
async maybeTrustDevice(ctx, cfg, accountId) {
|
|
281
|
+
if (!cfg.trustedDevices.enabled)
|
|
282
|
+
return;
|
|
283
|
+
const checked = ctx.request.input('trustDevice');
|
|
284
|
+
// Checkbox HTML: presente ('on'/'true'/'1') = marcado.
|
|
285
|
+
const on = checked === 'on' || checked === 'true' || checked === '1' || checked === true;
|
|
286
|
+
if (!on)
|
|
287
|
+
return;
|
|
288
|
+
const payload = buildTrustedDevicePayload(accountId, cfg.trustedDevices);
|
|
289
|
+
ctx.response.encryptedCookie(TRUSTED_DEVICE_COOKIE, payload, {
|
|
290
|
+
httpOnly: true,
|
|
291
|
+
sameSite: 'lax',
|
|
292
|
+
maxAge: cfg.trustedDevices.days * 24 * 60 * 60,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* accountId para uma cerimônia de passkey no login. Prioriza o accountId pendente
|
|
297
|
+
* do MFA (passkey como 2º fator). Quando ausente e o passkey-first está ligado,
|
|
298
|
+
* resolve a conta pelo e-mail guardado na sessão (passkey ANTES da senha) — só se
|
|
299
|
+
* a conta existe E tem ao menos uma passkey.
|
|
300
|
+
*/
|
|
301
|
+
async resolvePasskeyAccountId(ctx, cfg) {
|
|
302
|
+
const pending = ctx.session.get(MFA_PENDING_KEY);
|
|
303
|
+
if (pending)
|
|
304
|
+
return pending;
|
|
305
|
+
if (!cfg.passwordless?.passkeyFirst)
|
|
306
|
+
return undefined;
|
|
307
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
308
|
+
if (!email)
|
|
309
|
+
return undefined;
|
|
310
|
+
const acc = await cfg.accountStore.findByEmail(email);
|
|
311
|
+
if (!acc)
|
|
312
|
+
return undefined;
|
|
313
|
+
return (await this.hasPasskeys(cfg, acc.id)) ? acc.id : undefined;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* POST /auth/interaction/:uid/magic
|
|
317
|
+
* Magic link: lê o e-mail da sessão (passwordless.magicLink ligado), emite um
|
|
318
|
+
* token de uso único e dispara o e-mail. SEMPRE renderiza "link enviado",
|
|
319
|
+
* independentemente de a conta existir (anti-enumeração). Throttled como o login.
|
|
320
|
+
*/
|
|
321
|
+
async magicLinkRequest(ctx) {
|
|
322
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
323
|
+
const cfg = service.config;
|
|
324
|
+
const render = cfg.render;
|
|
325
|
+
const details = await service.interactions.details(ctx);
|
|
326
|
+
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
327
|
+
const email = ctx.session.get(SESSION_KEY);
|
|
328
|
+
const uid = ctx.request.param('uid');
|
|
329
|
+
if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
|
|
330
|
+
const issued = await cfg.accountStore.issueMagicLinkToken(email);
|
|
331
|
+
if (issued) {
|
|
332
|
+
await cfg.audit?.record({
|
|
333
|
+
type: 'login.magic_link_sent',
|
|
334
|
+
accountId: issued.account.id,
|
|
335
|
+
email,
|
|
336
|
+
ip: ctx.request.ip?.() ?? null,
|
|
337
|
+
clientId: details.params.client_id ?? null,
|
|
338
|
+
});
|
|
339
|
+
const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
|
|
340
|
+
const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
|
|
341
|
+
if (cfg.mail?.onMagicLink) {
|
|
342
|
+
await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token });
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
await sendMagicLinkEmail(ctx, { email, magicUrl });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Resposta uniforme (não vaza existência de conta).
|
|
350
|
+
return render(ctx, 'login', {
|
|
351
|
+
uid,
|
|
352
|
+
csrfToken: ctx.request.csrfToken,
|
|
353
|
+
step: 'password',
|
|
354
|
+
email,
|
|
355
|
+
account: null,
|
|
356
|
+
brand,
|
|
357
|
+
magicLinkSent: true,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* GET /auth/interaction/:uid/magic?token=...
|
|
362
|
+
* Consome o magic link. Em sucesso finaliza o login (amr `['email']`). Token
|
|
363
|
+
* inválido/expirado volta ao início do login.
|
|
364
|
+
*/
|
|
365
|
+
async magicLinkConsume(ctx) {
|
|
366
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
367
|
+
const cfg = service.config;
|
|
368
|
+
const uid = ctx.request.param('uid');
|
|
369
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
370
|
+
const clientId = (await service.interactions.details(ctx)).params.client_id;
|
|
371
|
+
const token = ctx.request.qs().token ?? '';
|
|
372
|
+
if (!cfg.passwordless.magicLink || !supportsMagicLink(cfg.accountStore) || !token) {
|
|
373
|
+
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
374
|
+
}
|
|
375
|
+
const acc = await cfg.accountStore.consumeMagicLinkToken(token);
|
|
376
|
+
if (!acc) {
|
|
377
|
+
await cfg.audit?.record({ type: 'login.failure', ip, clientId, metadata: { stage: 'magic_link' } });
|
|
378
|
+
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
379
|
+
}
|
|
380
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
381
|
+
accountId: acc.id,
|
|
382
|
+
email: acc.email,
|
|
383
|
+
ip,
|
|
384
|
+
clientId: clientId ?? null,
|
|
385
|
+
metadata: { method: 'magic_link' },
|
|
386
|
+
});
|
|
387
|
+
ctx.session.forget(SESSION_KEY);
|
|
388
|
+
await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
|
|
389
|
+
}
|
|
233
390
|
/**
|
|
234
391
|
* POST /auth/interaction/:uid/passkey/options
|
|
235
392
|
* Gera as opções de autenticação por passkey para o accountId pendente do MFA,
|
|
@@ -238,13 +395,17 @@ export default class AuthInteractionController {
|
|
|
238
395
|
async passkeyOptions(ctx) {
|
|
239
396
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
240
397
|
const cfg = service.config;
|
|
241
|
-
const accountId =
|
|
398
|
+
const accountId = await this.resolvePasskeyAccountId(ctx, cfg);
|
|
242
399
|
if (!accountId) {
|
|
243
|
-
return ctx.response.badRequest({
|
|
400
|
+
return ctx.response.badRequest({
|
|
401
|
+
message: translate(cfg.messages, 'errors.session_expired'),
|
|
402
|
+
});
|
|
244
403
|
}
|
|
245
404
|
const generated = await cfg.accountStore.generatePasskeyAuthenticationOptions?.(accountId);
|
|
246
405
|
if (!generated) {
|
|
247
|
-
return ctx.response.notFound({
|
|
406
|
+
return ctx.response.notFound({
|
|
407
|
+
message: translate(cfg.messages, 'errors.no_passkey_registered'),
|
|
408
|
+
});
|
|
248
409
|
}
|
|
249
410
|
ctx.session.put(PASSKEY_AUTH_CHALLENGE_KEY, generated.challenge);
|
|
250
411
|
return generated.options;
|
|
@@ -262,7 +423,7 @@ export default class AuthInteractionController {
|
|
|
262
423
|
const render = cfg.render;
|
|
263
424
|
const details = await service.interactions.details(ctx);
|
|
264
425
|
const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
|
|
265
|
-
const accountId =
|
|
426
|
+
const accountId = await this.resolvePasskeyAccountId(ctx, cfg);
|
|
266
427
|
const challenge = ctx.session.get(PASSKEY_AUTH_CHALLENGE_KEY);
|
|
267
428
|
const ip = ctx.request.ip?.() ?? null;
|
|
268
429
|
const clientId = details.params.client_id ?? null;
|
|
@@ -297,8 +458,12 @@ export default class AuthInteractionController {
|
|
|
297
458
|
error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
|
|
298
459
|
brand,
|
|
299
460
|
passkeyAvailable: await this.hasPasskeys(cfg, accountId),
|
|
461
|
+
trustedDevicesEnabled: cfg.trustedDevices.enabled,
|
|
462
|
+
trustedDeviceDays: cfg.trustedDevices.days,
|
|
300
463
|
});
|
|
301
464
|
}
|
|
465
|
+
// Passkey OK: opcionalmente confia neste dispositivo (checkbox no challenge).
|
|
466
|
+
await this.maybeTrustDevice(ctx, cfg, accountId);
|
|
302
467
|
ctx.session.forget(MFA_PENDING_KEY);
|
|
303
468
|
ctx.session.forget(SESSION_KEY);
|
|
304
469
|
await notifyLoginSuccess(ctx, cfg, {
|
|
@@ -307,7 +472,9 @@ export default class AuthInteractionController {
|
|
|
307
472
|
clientId,
|
|
308
473
|
metadata: { mfa: 'webauthn' },
|
|
309
474
|
});
|
|
310
|
-
|
|
475
|
+
// Step-up carimba acr/amr quando solicitado; senão a passkey conta como o fator
|
|
476
|
+
// forte do login (amr `['webauthn']`) — vale tanto p/ MFA quanto passkey-first.
|
|
477
|
+
await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, 'webauthn') ?? { amr: ['webauthn'] });
|
|
311
478
|
}
|
|
312
479
|
/**
|
|
313
480
|
* GET /auth/interaction/:uid/switch
|
|
@@ -45,6 +45,14 @@ export declare function sendNewLoginEmail(ctx: HttpContext, data: {
|
|
|
45
45
|
ip: string;
|
|
46
46
|
when: string;
|
|
47
47
|
}): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Envia o e-mail de magic link (login passwordless) pelo mailer default do host.
|
|
50
|
+
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|
|
51
|
+
*/
|
|
52
|
+
export declare function sendMagicLinkEmail(ctx: HttpContext, data: {
|
|
53
|
+
email: string;
|
|
54
|
+
magicUrl: string;
|
|
55
|
+
}): Promise<void>;
|
|
48
56
|
/**
|
|
49
57
|
* Envia o e-mail de verificação pelo mailer default do host.
|
|
50
58
|
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|
|
@@ -7,6 +7,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
9
|
import { renderTransactionalEmail } from './email_templates.js';
|
|
10
|
+
import { resolveMessages, translate, } from './i18n.js';
|
|
10
11
|
let mailServicePromise;
|
|
11
12
|
/**
|
|
12
13
|
* Importa o service de mail do HOST de forma preguiçosa e fail-safe.
|
|
@@ -78,6 +79,22 @@ function resolveBrand(ctx) {
|
|
|
78
79
|
}
|
|
79
80
|
return { appName: 'AuthKit' };
|
|
80
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve o catálogo de mensagens i18n a partir do `config/authkit.ts` (i18n).
|
|
84
|
+
* Cai no default (`en`) se a config não for resolvível. Usado para localizar
|
|
85
|
+
* os e-mails transacionais (assunto/cabeçalho/corpo).
|
|
86
|
+
*/
|
|
87
|
+
function resolveMailMessages(ctx) {
|
|
88
|
+
try {
|
|
89
|
+
const resolver = ctx.containerResolver;
|
|
90
|
+
const i18n = resolver.app?.config?.get?.('authkit')?.i18n;
|
|
91
|
+
return { messages: resolveMessages(i18n), locale: i18n?.locale ?? 'en' };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// sem config authkit resolvível — usa o default `en`.
|
|
95
|
+
return { messages: resolveMessages(), locale: 'en' };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
81
98
|
async function sendEmail(ctx, to, content) {
|
|
82
99
|
const mail = await loadMail();
|
|
83
100
|
if (!mail)
|
|
@@ -97,14 +114,17 @@ async function sendEmail(ctx, to, content) {
|
|
|
97
114
|
export async function sendPasswordResetEmail(ctx, data) {
|
|
98
115
|
try {
|
|
99
116
|
const brand = resolveBrand(ctx);
|
|
117
|
+
const { messages: t, locale } = resolveMailMessages(ctx);
|
|
100
118
|
const content = renderTransactionalEmail({
|
|
101
119
|
brand,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
locale,
|
|
121
|
+
linkFallback: translate(t, 'mail.common.link_fallback'),
|
|
122
|
+
subject: translate(t, 'mail.reset.subject'),
|
|
123
|
+
heading: translate(t, 'mail.reset.heading'),
|
|
124
|
+
intro: translate(t, 'mail.reset.intro'),
|
|
125
|
+
ctaLabel: translate(t, 'mail.reset.cta'),
|
|
106
126
|
ctaUrl: data.resetUrl,
|
|
107
|
-
footnote:
|
|
127
|
+
footnote: translate(t, 'mail.reset.fallback'),
|
|
108
128
|
});
|
|
109
129
|
const sent = await sendEmail(ctx, data.email, content);
|
|
110
130
|
if (!sent) {
|
|
@@ -122,14 +142,17 @@ export async function sendPasswordResetEmail(ctx, data) {
|
|
|
122
142
|
export async function sendEmailChangeConfirmationEmail(ctx, data) {
|
|
123
143
|
try {
|
|
124
144
|
const brand = resolveBrand(ctx);
|
|
145
|
+
const { messages: t, locale } = resolveMailMessages(ctx);
|
|
125
146
|
const content = renderTransactionalEmail({
|
|
126
147
|
brand,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
148
|
+
locale,
|
|
149
|
+
linkFallback: translate(t, 'mail.common.link_fallback'),
|
|
150
|
+
subject: translate(t, 'mail.email_change.subject'),
|
|
151
|
+
heading: translate(t, 'mail.email_change.heading'),
|
|
152
|
+
intro: translate(t, 'mail.email_change.intro'),
|
|
153
|
+
ctaLabel: translate(t, 'mail.email_change.cta'),
|
|
131
154
|
ctaUrl: data.confirmUrl,
|
|
132
|
-
footnote:
|
|
155
|
+
footnote: translate(t, 'mail.email_change.fallback'),
|
|
133
156
|
});
|
|
134
157
|
const sent = await sendEmail(ctx, data.email, content);
|
|
135
158
|
if (!sent) {
|
|
@@ -147,15 +170,23 @@ export async function sendEmailChangeConfirmationEmail(ctx, data) {
|
|
|
147
170
|
export async function sendNewLoginEmail(ctx, data) {
|
|
148
171
|
try {
|
|
149
172
|
const brand = resolveBrand(ctx);
|
|
173
|
+
const { messages: t, locale } = resolveMailMessages(ctx);
|
|
150
174
|
const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
|
|
175
|
+
const intro = [
|
|
176
|
+
translate(t, 'mail.new_login.intro'),
|
|
177
|
+
translate(t, 'mail.new_login.when', { date: data.when }),
|
|
178
|
+
translate(t, 'mail.new_login.ip', { ip: data.ip }),
|
|
179
|
+
].join(' ');
|
|
151
180
|
const content = renderTransactionalEmail({
|
|
152
181
|
brand,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
182
|
+
locale,
|
|
183
|
+
linkFallback: translate(t, 'mail.common.link_fallback'),
|
|
184
|
+
subject: translate(t, 'mail.new_login.subject'),
|
|
185
|
+
heading: translate(t, 'mail.new_login.heading'),
|
|
186
|
+
intro,
|
|
187
|
+
ctaLabel: translate(t, 'account.security.title'),
|
|
157
188
|
ctaUrl: `${origin}/account/security`,
|
|
158
|
-
footnote: '
|
|
189
|
+
footnote: translate(t, 'mail.new_login.fallback'),
|
|
159
190
|
});
|
|
160
191
|
const sent = await sendEmail(ctx, data.email, content);
|
|
161
192
|
if (!sent) {
|
|
@@ -166,6 +197,34 @@ export async function sendNewLoginEmail(ctx, data) {
|
|
|
166
197
|
ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar alerta de novo acesso');
|
|
167
198
|
}
|
|
168
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Envia o e-mail de magic link (login passwordless) pelo mailer default do host.
|
|
202
|
+
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|
|
203
|
+
*/
|
|
204
|
+
export async function sendMagicLinkEmail(ctx, data) {
|
|
205
|
+
try {
|
|
206
|
+
const brand = resolveBrand(ctx);
|
|
207
|
+
const { messages: t, locale } = resolveMailMessages(ctx);
|
|
208
|
+
const content = renderTransactionalEmail({
|
|
209
|
+
brand,
|
|
210
|
+
locale,
|
|
211
|
+
linkFallback: translate(t, 'mail.common.link_fallback'),
|
|
212
|
+
subject: translate(t, 'mail.magic_link.subject'),
|
|
213
|
+
heading: translate(t, 'mail.magic_link.heading'),
|
|
214
|
+
intro: translate(t, 'mail.magic_link.intro'),
|
|
215
|
+
ctaLabel: translate(t, 'mail.magic_link.cta'),
|
|
216
|
+
ctaUrl: data.magicUrl,
|
|
217
|
+
footnote: translate(t, 'mail.magic_link.fallback'),
|
|
218
|
+
});
|
|
219
|
+
const sent = await sendEmail(ctx, data.email, content);
|
|
220
|
+
if (!sent) {
|
|
221
|
+
ctx.logger.info({ magicUrl: data.magicUrl, email: data.email }, 'authkit: magic link de login (dev — @adonisjs/mail ausente)');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar magic link de login');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
169
228
|
/**
|
|
170
229
|
* Envia o e-mail de verificação pelo mailer default do host.
|
|
171
230
|
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|
|
@@ -173,12 +232,15 @@ export async function sendNewLoginEmail(ctx, data) {
|
|
|
173
232
|
export async function sendEmailVerificationEmail(ctx, data) {
|
|
174
233
|
try {
|
|
175
234
|
const brand = resolveBrand(ctx);
|
|
235
|
+
const { messages: t, locale } = resolveMailMessages(ctx);
|
|
176
236
|
const content = renderTransactionalEmail({
|
|
177
237
|
brand,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
238
|
+
locale,
|
|
239
|
+
linkFallback: translate(t, 'mail.common.link_fallback'),
|
|
240
|
+
subject: translate(t, 'mail.verify.subject'),
|
|
241
|
+
heading: translate(t, 'mail.verify.heading'),
|
|
242
|
+
intro: translate(t, 'mail.verify.intro'),
|
|
243
|
+
ctaLabel: translate(t, 'mail.verify.cta'),
|
|
182
244
|
ctaUrl: data.verifyUrl,
|
|
183
245
|
});
|
|
184
246
|
const sent = await sendEmail(ctx, data.email, content);
|
|
@@ -30,6 +30,10 @@ interface EmailTemplateInput {
|
|
|
30
30
|
ctaUrl: string;
|
|
31
31
|
/** Linha auxiliar abaixo do botão (ex.: validade do link). */
|
|
32
32
|
footnote?: string;
|
|
33
|
+
/** Texto que precede o link de fallback (i18n). Default em inglês. */
|
|
34
|
+
linkFallback?: string;
|
|
35
|
+
/** Locale do documento HTML (atributo `lang`). Default: 'en'. */
|
|
36
|
+
locale?: string;
|
|
33
37
|
}
|
|
34
38
|
export declare function renderTransactionalEmail(input: EmailTemplateInput): EmailContent;
|
|
35
39
|
export {};
|
|
@@ -21,8 +21,11 @@ export function renderTransactionalEmail(input) {
|
|
|
21
21
|
const accent = input.brand.accent || FALLBACK_ACCENT;
|
|
22
22
|
const company = input.brand.company || appName;
|
|
23
23
|
const year = '©'; // ano resolvido fora (sem Date.* aqui); rodapé usa só o nome.
|
|
24
|
+
const lang = input.locale || 'en';
|
|
25
|
+
const linkFallback = input.linkFallback ||
|
|
26
|
+
'If the button does not work, copy and paste this link into your browser:';
|
|
24
27
|
const html = `<!doctype html>
|
|
25
|
-
<html lang="
|
|
28
|
+
<html lang="${esc(lang)}">
|
|
26
29
|
<head>
|
|
27
30
|
<meta charset="utf-8">
|
|
28
31
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -42,7 +45,7 @@ export function renderTransactionalEmail(input) {
|
|
|
42
45
|
<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>
|
|
43
46
|
</td></tr></table>
|
|
44
47
|
${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(input.footnote)}</p>` : ''}
|
|
45
|
-
<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;"
|
|
48
|
+
<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>
|
|
46
49
|
</td></tr>
|
|
47
50
|
<tr><td style="padding:24px 28px 28px;border-top:1px solid #f3f4f6;">
|
|
48
51
|
<p style="margin:0;font-size:12px;line-height:1.5;color:#9ca3af;">${esc(company)} ${year}</p>
|