@dudousxd/adonis-authkit-server 0.3.0 → 0.5.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/build/commands/commands.json +28 -0
- package/build/commands/doctor.d.ts +10 -0
- package/build/commands/doctor.js +66 -0
- package/build/commands/rotate_keys.d.ts +10 -0
- package/build/commands/rotate_keys.js +53 -0
- package/build/host/views/account/email-confirmed.edge +15 -0
- package/build/host/views/account/security.edge +83 -0
- package/build/host/views/account/tokens.edge +7 -4
- package/build/host/views/admin/sessions.edge +89 -0
- package/build/host/views/admin/users.edge +1 -0
- package/build/host/views/mfa-challenge.edge +29 -23
- package/build/index.d.ts +5 -4
- package/build/index.js +3 -3
- package/build/src/accounts/account_store.d.ts +46 -1
- package/build/src/accounts/account_store.js +4 -0
- package/build/src/accounts/lucid_store/core.d.ts +5 -4
- package/build/src/accounts/lucid_store/core.js +67 -2
- package/build/src/adapters/adapter_contract.d.ts +17 -0
- package/build/src/adapters/database_adapter.d.ts +9 -5
- package/build/src/adapters/database_adapter.js +13 -6
- package/build/src/adapters/redis_adapter.d.ts +11 -5
- package/build/src/adapters/redis_adapter.js +16 -7
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/define_config.d.ts +102 -0
- package/build/src/define_config.js +46 -3
- package/build/src/doctor/checks.d.ts +51 -0
- package/build/src/doctor/checks.js +231 -0
- package/build/src/host/admin_clients_service.js +12 -5
- package/build/src/host/admin_sessions_service.d.ts +63 -0
- package/build/src/host/admin_sessions_service.js +127 -0
- package/build/src/host/controllers/account_mfa_controller.js +6 -2
- package/build/src/host/controllers/account_security_controller.d.ts +16 -0
- package/build/src/host/controllers/account_security_controller.js +119 -0
- package/build/src/host/controllers/account_session_controller.js +2 -1
- package/build/src/host/controllers/admin/admin_sessions_controller.d.ts +14 -0
- package/build/src/host/controllers/admin/admin_sessions_controller.js +64 -0
- package/build/src/host/controllers/interaction_controller.d.ts +11 -0
- package/build/src/host/controllers/interaction_controller.js +55 -12
- package/build/src/host/default_mailer.d.ts +17 -0
- package/build/src/host/default_mailer.js +94 -9
- 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 +358 -11
- package/build/src/host/i18n.js +393 -12
- package/build/src/host/login_notify.d.ts +20 -0
- package/build/src/host/login_notify.js +71 -0
- package/build/src/host/register_auth_host.js +12 -0
- package/build/src/host/validators.d.ts +32 -0
- package/build/src/host/validators.js +14 -0
- package/build/src/keys/keystore.d.ts +43 -0
- package/build/src/keys/keystore.js +74 -0
- package/build/src/observability/metrics_controller.js +4 -4
- package/build/src/provider/build_provider.js +23 -0
- package/build/src/provider/device_sources.d.ts +6 -0
- package/build/src/provider/device_sources.js +65 -0
- package/build/src/provider/interaction_actions.d.ts +6 -1
- package/build/src/provider/interaction_actions.js +9 -2
- package/package.json +2 -2
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import '../../augmentations.js';
|
|
2
|
+
import { ACCOUNT_SESSION_KEY } from '../../middleware/account_auth.js';
|
|
3
|
+
import { AdminSessionsService } from '../../admin_sessions_service.js';
|
|
4
|
+
/**
|
|
5
|
+
* Inspeção/revogação das sessões e grants ativos de uma conta no console admin.
|
|
6
|
+
* Lista as `Session` (logins do IdP) + os `Grant` (autorizações por client) da
|
|
7
|
+
* conta, com a contagem de access/refresh tokens por grant. Degrada graciosamente
|
|
8
|
+
* quando o adapter OIDC não enumera (`list`), espelhando o CRUD de clients.
|
|
9
|
+
*/
|
|
10
|
+
export default class AdminSessionsController {
|
|
11
|
+
/** GET /admin/users/:id/sessions — lista sessões + grants da conta. */
|
|
12
|
+
async index(ctx) {
|
|
13
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
14
|
+
const cfg = service.config;
|
|
15
|
+
const render = cfg.render;
|
|
16
|
+
const accountId = ctx.request.param('id');
|
|
17
|
+
const account = await cfg.accountStore.findById(accountId);
|
|
18
|
+
const sessions = new AdminSessionsService(service);
|
|
19
|
+
const supported = sessions.canList;
|
|
20
|
+
const sessionList = supported ? await sessions.listSessions(accountId) : [];
|
|
21
|
+
const grantList = supported ? await sessions.listGrants(accountId) : [];
|
|
22
|
+
const revoked = ctx.session.flashMessages.get('sessionsRevoked');
|
|
23
|
+
return render(ctx, 'admin/sessions', {
|
|
24
|
+
csrfToken: ctx.request.csrfToken,
|
|
25
|
+
supported,
|
|
26
|
+
accountId,
|
|
27
|
+
email: account?.email ?? '',
|
|
28
|
+
revoked: revoked ?? null,
|
|
29
|
+
sessions: sessionList.map((s) => ({
|
|
30
|
+
id: s.id,
|
|
31
|
+
loginTs: s.loginTs ? new Date(s.loginTs * 1000).toISOString() : '',
|
|
32
|
+
amr: (s.amr ?? []).join(', '),
|
|
33
|
+
})),
|
|
34
|
+
grants: grantList.map((g) => ({
|
|
35
|
+
id: g.id,
|
|
36
|
+
clientId: g.clientId ?? '',
|
|
37
|
+
accessTokens: g.accessTokens,
|
|
38
|
+
refreshTokens: g.refreshTokens,
|
|
39
|
+
})),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** POST /admin/users/:id/revoke-sessions — destrói sessões + grants da conta. */
|
|
43
|
+
async revoke(ctx) {
|
|
44
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
45
|
+
const cfg = service.config;
|
|
46
|
+
const accountId = ctx.request.param('id');
|
|
47
|
+
const sessions = new AdminSessionsService(service);
|
|
48
|
+
const result = await sessions.revokeAll(accountId);
|
|
49
|
+
await cfg.audit?.record({
|
|
50
|
+
type: 'session.revoked_all',
|
|
51
|
+
accountId,
|
|
52
|
+
actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
|
|
53
|
+
ip: ctx.request.ip?.() ?? null,
|
|
54
|
+
metadata: {
|
|
55
|
+
sessions: result.sessions,
|
|
56
|
+
grants: result.grants,
|
|
57
|
+
accessTokens: result.accessTokens,
|
|
58
|
+
refreshTokens: result.refreshTokens,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
ctx.session.flash('sessionsRevoked', result);
|
|
62
|
+
return ctx.response.redirect(`/admin/users/${accountId}/sessions`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -19,6 +19,17 @@ export default class AuthInteractionController {
|
|
|
19
19
|
* OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
|
|
20
20
|
*/
|
|
21
21
|
mfaVerify(ctx: HttpContext): Promise<any>;
|
|
22
|
+
/**
|
|
23
|
+
* true se o authorize request exige MFA via acr_values (contém o mfaAcr da
|
|
24
|
+
* config de step-up). `acr_values` é a string separada por espaços padrão OIDC.
|
|
25
|
+
*/
|
|
26
|
+
private acrRequiresMfa;
|
|
27
|
+
/**
|
|
28
|
+
* Monta o acr/amr do step-up quando o client solicitou o mfaAcr e um 2º fator
|
|
29
|
+
* foi verificado. `method` é o método do segundo fator (totp/recovery/webauthn).
|
|
30
|
+
* Retorna `undefined` quando não há step-up — completeLogin usa o default.
|
|
31
|
+
*/
|
|
32
|
+
private stepUpExtra;
|
|
22
33
|
/** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
|
|
23
34
|
private hasPasskeys;
|
|
24
35
|
/**
|
|
@@ -2,6 +2,7 @@ import '../augmentations.js';
|
|
|
2
2
|
import { brandFor, isFirstParty } from '../branding.js';
|
|
3
3
|
import { translate } from '../i18n.js';
|
|
4
4
|
import { attemptPasswordLogin } from '../login_attempt.js';
|
|
5
|
+
import { notifyLoginSuccess } from '../login_notify.js';
|
|
5
6
|
import { supportsPasskeys } from '../../accounts/account_store.js';
|
|
6
7
|
const SESSION_KEY = 'authkit_login_email';
|
|
7
8
|
/** accountId aguardando o 2º fator depois da senha verificada. */
|
|
@@ -106,10 +107,25 @@ export default class AuthInteractionController {
|
|
|
106
107
|
});
|
|
107
108
|
}
|
|
108
109
|
const acc = result.account;
|
|
109
|
-
//
|
|
110
|
-
//
|
|
110
|
+
// Step-up auth (acr_values): o client pode EXIGIR MFA nesta requisição
|
|
111
|
+
// solicitando o `mfaAcr` em acr_values, mesmo que a conta tenha MFA opcional.
|
|
112
|
+
const mfaRequired = this.acrRequiresMfa(cfg, details);
|
|
113
|
+
// MFA gate: força o 2º fator se a conta tem TOTP ativo OU se o client exige MFA
|
|
114
|
+
// via acr. Não finaliza a interaction agora — guarda o accountId pendente.
|
|
111
115
|
const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
|
|
112
|
-
if (mfa.enabled) {
|
|
116
|
+
if (mfa.enabled || mfaRequired) {
|
|
117
|
+
if (mfaRequired && !mfa.enabled) {
|
|
118
|
+
// Client exige MFA mas a conta não tem MFA enrolado: bloqueia este login
|
|
119
|
+
// com a instrução de configurar MFA no console (não há 2º fator a desafiar).
|
|
120
|
+
return render(ctx, 'mfa-challenge', {
|
|
121
|
+
uid: ctx.request.param('uid'),
|
|
122
|
+
csrfToken: ctx.request.csrfToken,
|
|
123
|
+
brand,
|
|
124
|
+
passkeyAvailable: false,
|
|
125
|
+
error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
|
|
126
|
+
noEnrollment: true,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
113
129
|
ctx.session.put(MFA_PENDING_KEY, acc.id);
|
|
114
130
|
// Passkey disponível como alternativa ao TOTP se o store suporta E a conta
|
|
115
131
|
// tem ao menos uma credencial registrada.
|
|
@@ -123,7 +139,7 @@ export default class AuthInteractionController {
|
|
|
123
139
|
}
|
|
124
140
|
// Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
|
|
125
141
|
await service.interactions.completeLogin(ctx, acc.id);
|
|
126
|
-
await
|
|
142
|
+
await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip, clientId });
|
|
127
143
|
// Clean up the session key after a successful login.
|
|
128
144
|
ctx.session.forget(SESSION_KEY);
|
|
129
145
|
}
|
|
@@ -174,14 +190,38 @@ export default class AuthInteractionController {
|
|
|
174
190
|
// Sucesso no 2º fator: finaliza a interaction para o accountId pendente.
|
|
175
191
|
ctx.session.forget(MFA_PENDING_KEY);
|
|
176
192
|
ctx.session.forget(SESSION_KEY);
|
|
177
|
-
await cfg
|
|
178
|
-
type: 'login.success',
|
|
193
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
179
194
|
accountId,
|
|
180
195
|
ip,
|
|
181
196
|
clientId,
|
|
182
197
|
metadata: { mfa: usedRecovery ? 'recovery' : 'totp' },
|
|
183
198
|
});
|
|
184
|
-
|
|
199
|
+
// Step-up: um 2º fator foi de fato verificado — carimba acr/amr no id_token
|
|
200
|
+
// se o client solicitou o mfaAcr nesta requisição.
|
|
201
|
+
await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, usedRecovery ? 'recovery' : 'totp'));
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* true se o authorize request exige MFA via acr_values (contém o mfaAcr da
|
|
205
|
+
* config de step-up). `acr_values` é a string separada por espaços padrão OIDC.
|
|
206
|
+
*/
|
|
207
|
+
acrRequiresMfa(cfg, details) {
|
|
208
|
+
const mfaAcr = cfg.stepUp?.mfaAcr;
|
|
209
|
+
if (!mfaAcr)
|
|
210
|
+
return false;
|
|
211
|
+
const raw = details?.params?.acr_values;
|
|
212
|
+
if (typeof raw !== 'string' || !raw)
|
|
213
|
+
return false;
|
|
214
|
+
return raw.split(/\s+/).includes(mfaAcr);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Monta o acr/amr do step-up quando o client solicitou o mfaAcr e um 2º fator
|
|
218
|
+
* foi verificado. `method` é o método do segundo fator (totp/recovery/webauthn).
|
|
219
|
+
* Retorna `undefined` quando não há step-up — completeLogin usa o default.
|
|
220
|
+
*/
|
|
221
|
+
stepUpExtra(cfg, details, method) {
|
|
222
|
+
if (!this.acrRequiresMfa(cfg, details))
|
|
223
|
+
return undefined;
|
|
224
|
+
return { acr: cfg.stepUp.mfaAcr, amr: ['mfa', method] };
|
|
185
225
|
}
|
|
186
226
|
/** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
|
|
187
227
|
async hasPasskeys(cfg, accountId) {
|
|
@@ -200,11 +240,15 @@ export default class AuthInteractionController {
|
|
|
200
240
|
const cfg = service.config;
|
|
201
241
|
const accountId = ctx.session.get(MFA_PENDING_KEY);
|
|
202
242
|
if (!accountId) {
|
|
203
|
-
return ctx.response.badRequest({
|
|
243
|
+
return ctx.response.badRequest({
|
|
244
|
+
message: translate(cfg.messages, 'errors.session_expired'),
|
|
245
|
+
});
|
|
204
246
|
}
|
|
205
247
|
const generated = await cfg.accountStore.generatePasskeyAuthenticationOptions?.(accountId);
|
|
206
248
|
if (!generated) {
|
|
207
|
-
return ctx.response.notFound({
|
|
249
|
+
return ctx.response.notFound({
|
|
250
|
+
message: translate(cfg.messages, 'errors.no_passkey_registered'),
|
|
251
|
+
});
|
|
208
252
|
}
|
|
209
253
|
ctx.session.put(PASSKEY_AUTH_CHALLENGE_KEY, generated.challenge);
|
|
210
254
|
return generated.options;
|
|
@@ -261,14 +305,13 @@ export default class AuthInteractionController {
|
|
|
261
305
|
}
|
|
262
306
|
ctx.session.forget(MFA_PENDING_KEY);
|
|
263
307
|
ctx.session.forget(SESSION_KEY);
|
|
264
|
-
await cfg
|
|
265
|
-
type: 'login.success',
|
|
308
|
+
await notifyLoginSuccess(ctx, cfg, {
|
|
266
309
|
accountId,
|
|
267
310
|
ip,
|
|
268
311
|
clientId,
|
|
269
312
|
metadata: { mfa: 'webauthn' },
|
|
270
313
|
});
|
|
271
|
-
await service.interactions.completeLogin(ctx, accountId);
|
|
314
|
+
await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, 'webauthn'));
|
|
272
315
|
}
|
|
273
316
|
/**
|
|
274
317
|
* GET /auth/interaction/:uid/switch
|
|
@@ -28,6 +28,23 @@ export declare function sendPasswordResetEmail(ctx: HttpContext, data: {
|
|
|
28
28
|
email: string;
|
|
29
29
|
resetUrl: string;
|
|
30
30
|
}): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Envia o e-mail de confirmação de TROCA de e-mail para o NOVO endereço.
|
|
33
|
+
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|
|
34
|
+
*/
|
|
35
|
+
export declare function sendEmailChangeConfirmationEmail(ctx: HttpContext, data: {
|
|
36
|
+
email: string;
|
|
37
|
+
confirmUrl: string;
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Envia o e-mail de alerta de NOVO acesso à conta (login de um IP novo).
|
|
41
|
+
* Best-effort: no fallback (sem mail) loga o evento; nunca lança.
|
|
42
|
+
*/
|
|
43
|
+
export declare function sendNewLoginEmail(ctx: HttpContext, data: {
|
|
44
|
+
email: string;
|
|
45
|
+
ip: string;
|
|
46
|
+
when: string;
|
|
47
|
+
}): Promise<void>;
|
|
31
48
|
/**
|
|
32
49
|
* Envia o e-mail de verificação pelo mailer default do host.
|
|
33
50
|
* 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) {
|
|
@@ -115,6 +135,68 @@ export async function sendPasswordResetEmail(ctx, data) {
|
|
|
115
135
|
ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar e-mail de redefinição de senha');
|
|
116
136
|
}
|
|
117
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Envia o e-mail de confirmação de TROCA de e-mail para o NOVO endereço.
|
|
140
|
+
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|
|
141
|
+
*/
|
|
142
|
+
export async function sendEmailChangeConfirmationEmail(ctx, data) {
|
|
143
|
+
try {
|
|
144
|
+
const brand = resolveBrand(ctx);
|
|
145
|
+
const { messages: t, locale } = resolveMailMessages(ctx);
|
|
146
|
+
const content = renderTransactionalEmail({
|
|
147
|
+
brand,
|
|
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'),
|
|
154
|
+
ctaUrl: data.confirmUrl,
|
|
155
|
+
footnote: translate(t, 'mail.email_change.fallback'),
|
|
156
|
+
});
|
|
157
|
+
const sent = await sendEmail(ctx, data.email, content);
|
|
158
|
+
if (!sent) {
|
|
159
|
+
ctx.logger.info({ confirmUrl: data.confirmUrl, email: data.email }, 'authkit: link de confirmação de troca de e-mail (dev — @adonisjs/mail ausente)');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar confirmação de troca de e-mail');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Envia o e-mail de alerta de NOVO acesso à conta (login de um IP novo).
|
|
168
|
+
* Best-effort: no fallback (sem mail) loga o evento; nunca lança.
|
|
169
|
+
*/
|
|
170
|
+
export async function sendNewLoginEmail(ctx, data) {
|
|
171
|
+
try {
|
|
172
|
+
const brand = resolveBrand(ctx);
|
|
173
|
+
const { messages: t, locale } = resolveMailMessages(ctx);
|
|
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(' ');
|
|
180
|
+
const content = renderTransactionalEmail({
|
|
181
|
+
brand,
|
|
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'),
|
|
188
|
+
ctaUrl: `${origin}/account/security`,
|
|
189
|
+
footnote: translate(t, 'mail.new_login.fallback'),
|
|
190
|
+
});
|
|
191
|
+
const sent = await sendEmail(ctx, data.email, content);
|
|
192
|
+
if (!sent) {
|
|
193
|
+
ctx.logger.info({ ip: data.ip, email: data.email }, 'authkit: alerta de novo acesso (dev — @adonisjs/mail ausente)');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar alerta de novo acesso');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
118
200
|
/**
|
|
119
201
|
* Envia o e-mail de verificação pelo mailer default do host.
|
|
120
202
|
* Best-effort: no fallback (sem mail) loga o link; nunca lança.
|
|
@@ -122,12 +204,15 @@ export async function sendPasswordResetEmail(ctx, data) {
|
|
|
122
204
|
export async function sendEmailVerificationEmail(ctx, data) {
|
|
123
205
|
try {
|
|
124
206
|
const brand = resolveBrand(ctx);
|
|
207
|
+
const { messages: t, locale } = resolveMailMessages(ctx);
|
|
125
208
|
const content = renderTransactionalEmail({
|
|
126
209
|
brand,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
210
|
+
locale,
|
|
211
|
+
linkFallback: translate(t, 'mail.common.link_fallback'),
|
|
212
|
+
subject: translate(t, 'mail.verify.subject'),
|
|
213
|
+
heading: translate(t, 'mail.verify.heading'),
|
|
214
|
+
intro: translate(t, 'mail.verify.intro'),
|
|
215
|
+
ctaLabel: translate(t, 'mail.verify.cta'),
|
|
131
216
|
ctaUrl: data.verifyUrl,
|
|
132
217
|
});
|
|
133
218
|
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>
|