@dax-crafta/auth 2.1.6 → 2.1.8

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.
@@ -0,0 +1,66 @@
1
+ // packages/auth/src/services/passwordless.service.js
2
+ /**
3
+ * PasswordlessService — magic-link login.
4
+ *
5
+ * Flow:
6
+ * 1. POST /auth/magic-link { email } → creates a single-use token, emails it
7
+ * 2. GET /auth/magic-link/verify?token=... → validates, logs user in
8
+ *
9
+ * Tokens are short-lived (default 15 min) and single-use.
10
+ */
11
+ const crypto = require('crypto');
12
+ const ApiError = require('../utils/api-error');
13
+
14
+ class PasswordlessService {
15
+ constructor(config = {}) {
16
+ this.config = config;
17
+ this.emailService = config.emailService;
18
+ this.tokenTtlMs = (config.passwordless && config.passwordless.tokenTtlMs) || 15 * 60 * 1000;
19
+ }
20
+
21
+ generateToken() {
22
+ return crypto.randomBytes(32).toString('hex');
23
+ }
24
+
25
+ async requestMagicLink(email, baseUrl) {
26
+ const User = require('../models/user');
27
+ const user = await User.findOne({ email });
28
+ // Don't reveal whether the email exists — just return success either way.
29
+ if (!user) return { sent: false, reason: 'user_not_found' };
30
+
31
+ const token = this.generateToken();
32
+ user.magicLinkToken = token;
33
+ user.magicLinkExpires = Date.now() + this.tokenTtlMs;
34
+ await user.save();
35
+
36
+ const linkPath = this.config.routes?.magicLink || '/auth/magic-link/verify';
37
+ const url = `${baseUrl || ''}${linkPath}?token=${token}`;
38
+
39
+ if (this.emailService) {
40
+ await this.emailService.sendMagicLink(user, url);
41
+ }
42
+
43
+ return { sent: true };
44
+ }
45
+
46
+ async verifyMagicLink(token) {
47
+ const User = require('../models/user');
48
+ const user = await User.findOne({
49
+ magicLinkToken: token,
50
+ magicLinkExpires: { $gt: Date.now() }
51
+ }).select('+magicLinkToken +magicLinkExpires');
52
+
53
+ if (!user) {
54
+ throw ApiError.invalidToken('Invalid or expired magic link');
55
+ }
56
+
57
+ // Single-use: consume the token.
58
+ user.magicLinkToken = undefined;
59
+ user.magicLinkExpires = undefined;
60
+ await user.save();
61
+
62
+ return user;
63
+ }
64
+ }
65
+
66
+ module.exports = PasswordlessService;
@@ -1,7 +1,12 @@
1
1
  const Role = require('../models/role');
2
+ const ApiError = require('../utils/api-error');
2
3
 
3
4
  class RoleService {
4
5
  async createRole(roleData) {
6
+ const existing = await Role.findOne({ name: roleData.name });
7
+ if (existing) {
8
+ throw ApiError.conflict(`Role "${roleData.name}" already exists`);
9
+ }
5
10
  const role = new Role(roleData);
6
11
  await role.save();
7
12
  return role;
@@ -0,0 +1,141 @@
1
+ // packages/auth/src/services/session.service.js
2
+ /**
3
+ * SessionService — manages user sessions with a global "session version"
4
+ * stamp so that a password change, admin action, or logout-all can invalidate
5
+ * every existing JWT issued before the event.
6
+ *
7
+ * This is the backbone of true "log out everywhere" behaviour.
8
+ */
9
+ const ApiError = require('../utils/api-error');
10
+
11
+ class SessionService {
12
+ constructor(config = {}) {
13
+ this.config = config;
14
+ this.maxSessions = (config.session && config.session.maxSessions) || 5;
15
+ this.sessionExpiryMs =
16
+ (config.session && config.session.sessionExpiryMs) || 30 * 24 * 60 * 60 * 1000; // 30d
17
+ this.ipBinding = (config.session && config.session.ipBinding) || false;
18
+ }
19
+
20
+ /**
21
+ * Bump the user's session version. Every existing JWT issued before this
22
+ * moment becomes invalid.
23
+ */
24
+ async bumpSessionVersion(user) {
25
+ user.sessionVersion = (user.sessionVersion || 0) + 1;
26
+ await user.save();
27
+ return user.sessionVersion;
28
+ }
29
+
30
+ /**
31
+ * Enforce per-user session cap. If the user has more active sessions than
32
+ * `maxSessions`, drop the oldest ones.
33
+ */
34
+ async enforceSessionCap(user) {
35
+ const sessions = user.sessions || [];
36
+ if (sessions.length <= this.maxSessions) return;
37
+
38
+ // sort oldest-first by createdAt
39
+ sessions.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
40
+ const toRemove = sessions.length - this.maxSessions;
41
+ sessions.splice(0, toRemove);
42
+ user.sessions = sessions;
43
+ await user.save();
44
+ }
45
+
46
+ /**
47
+ * Validate that a decoded JWT's sessionVersion matches the user's current
48
+ * version. If not, the session has been revoked globally.
49
+ */
50
+ validateSession(decoded, user, currentIp = null) {
51
+ const tokenVersion = decoded.sessionVersion || 0;
52
+ const currentVersion = user.sessionVersion || 0;
53
+ if (tokenVersion !== currentVersion) {
54
+ throw ApiError.unauthorized(
55
+ 'Session has been invalidated. Please log in again.'
56
+ );
57
+ }
58
+
59
+ if (this.ipBinding && currentIp && decoded.ip && decoded.ip !== currentIp) {
60
+ throw ApiError.unauthorized(
61
+ 'Session IP address mismatch. Please log in again.'
62
+ );
63
+ }
64
+
65
+ return true;
66
+ }
67
+
68
+ /**
69
+ * Create a new session record for a user (used during login / social login).
70
+ *
71
+ * Tracks device info (userAgent, IP) and timestamps. If a session for the
72
+ * same device already exists, updates its activity timestamp instead of
73
+ * creating a duplicate — preventing session bloat from repeated logins.
74
+ */
75
+ async createSession(user, info = {}) {
76
+ const crypto = require('crypto');
77
+ const userAgent = info.userAgent || 'unknown';
78
+ const ip = info.ip || 'unknown';
79
+
80
+ user.sessions = user.sessions || [];
81
+
82
+ // Reuse an existing session from the same device: refresh activity stamp.
83
+ const existing = user.sessions.find(
84
+ (s) => s.userAgent === userAgent && s.ip === ip
85
+ );
86
+ if (existing) {
87
+ existing.lastActiveAt = new Date();
88
+ await this.enforceSessionCap(user);
89
+ return existing;
90
+ }
91
+
92
+ const session = {
93
+ sessionId: crypto.randomBytes(16).toString('hex'),
94
+ userAgent,
95
+ ip,
96
+ createdAt: new Date(),
97
+ lastActiveAt: new Date()
98
+ };
99
+ user.sessions.push(session);
100
+ await this.enforceSessionCap(user);
101
+ return session;
102
+ }
103
+
104
+ /**
105
+ * Touch a session's activity timestamp (e.g. on token refresh).
106
+ * No-op if the session is not found.
107
+ */
108
+ async touchSession(user, sessionId) {
109
+ if (!user.sessions) return null;
110
+ const session = user.sessions.find((s) => s.sessionId === sessionId);
111
+ if (session) {
112
+ session.lastActiveAt = new Date();
113
+ await user.save();
114
+ }
115
+ return session;
116
+ }
117
+
118
+ /**
119
+ * Revoke a single session by sessionId.
120
+ */
121
+ async revokeSession(user, sessionId) {
122
+ if (!user.sessions) return;
123
+ user.sessions = user.sessions.filter((s) => s.sessionId !== sessionId);
124
+ await user.save();
125
+ }
126
+
127
+ /**
128
+ * Revoke ALL sessions for a user (logout everywhere).
129
+ */
130
+ async revokeAllSessions(user) {
131
+ user.sessions = [];
132
+ await this.save(user);
133
+ return this.bumpSessionVersion(user);
134
+ }
135
+
136
+ async save(user) {
137
+ await user.save();
138
+ }
139
+ }
140
+
141
+ module.exports = SessionService;
@@ -0,0 +1,134 @@
1
+ // packages/auth/src/services/webhook.service.js
2
+ /**
3
+ * WebhookService — fire HTTP callbacks for auth events so downstream
4
+ * systems (CRM, analytics, SIEM) can react to registration, login, etc.
5
+ *
6
+ * Failures are swallowed (logged) so they never break auth flows.
7
+ */
8
+ const https = require('https');
9
+ const http = require('http');
10
+ const URL = require('url');
11
+ const crypto = require('crypto');
12
+
13
+ class WebhookService {
14
+ constructor(config = {}) {
15
+ this.config = config;
16
+ this.enabled = !!(config.webhooks && config.webhooks.url);
17
+ this.url = this.enabled ? config.webhooks.url : null;
18
+ this.secret = this.enabled ? config.webhooks.secret : null;
19
+ this.events = this.enabled
20
+ ? config.webhooks.events || ['user.registered', 'user.login', 'user.logout', 'user.password_changed']
21
+ : [];
22
+ this.deliveryStats = {
23
+ total: 0,
24
+ success: 0,
25
+ failure: 0,
26
+ lastEvent: null,
27
+ lastStatus: null
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Sign the payload with HMAC-SHA256 so receivers can verify authenticity.
33
+ */
34
+ sign(payload) {
35
+ if (!this.secret) return null;
36
+ return crypto
37
+ .createHmac('sha256', this.secret)
38
+ .update(payload)
39
+ .digest('hex');
40
+ }
41
+
42
+ /**
43
+ * Fire a webhook event. Non-blocking — failures are caught and returned,
44
+ * never thrown.
45
+ */
46
+ async emit(event, data = {}) {
47
+ if (!this.enabled) return { delivered: false, reason: 'webhooks_disabled' };
48
+ if (this.events.length && !this.events.includes(event)) {
49
+ return { delivered: false, reason: 'event_not_subscribed', event };
50
+ }
51
+
52
+ const body = JSON.stringify({
53
+ event,
54
+ timestamp: new Date().toISOString(),
55
+ data
56
+ });
57
+ const signature = this.sign(body);
58
+
59
+ const parsed = URL.parse(this.url);
60
+ const transport = parsed.protocol === 'https:' ? https : http;
61
+
62
+ return new Promise((resolve) => {
63
+ const req = transport.request(
64
+ {
65
+ hostname: parsed.hostname,
66
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
67
+ path: parsed.path || '/',
68
+ method: 'POST',
69
+ headers: {
70
+ 'Content-Type': 'application/json',
71
+ 'Content-Length': Buffer.byteLength(body),
72
+ 'X-Crafta-Event': event,
73
+ ...(signature ? { 'X-Crafta-Signature': signature } : {})
74
+ }
75
+ },
76
+ (res) => {
77
+ let payload = '';
78
+ res.setEncoding('utf8');
79
+ res.on('data', (chunk) => (payload += chunk));
80
+ res.on('end', () => {
81
+ const success = res.statusCode >= 200 && res.statusCode < 300;
82
+ this.deliveryStats.total++;
83
+ if (success) {
84
+ this.deliveryStats.success++;
85
+ } else {
86
+ this.deliveryStats.failure++;
87
+ }
88
+ this.deliveryStats.lastEvent = event;
89
+ this.deliveryStats.lastStatus = res.statusCode;
90
+
91
+ resolve({
92
+ delivered: success,
93
+ status: res.statusCode,
94
+ body: payload.slice(0, 500)
95
+ });
96
+ });
97
+ }
98
+ );
99
+
100
+ req.on('error', (err) => {
101
+ this.deliveryStats.total++;
102
+ this.deliveryStats.failure++;
103
+ this.deliveryStats.lastEvent = event;
104
+ this.deliveryStats.lastStatus = 'error';
105
+
106
+ resolve({ delivered: false, reason: err.message });
107
+ });
108
+
109
+ req.setTimeout(5000, () => {
110
+ req.destroy();
111
+ resolve({ delivered: false, reason: 'request_timeout' });
112
+ });
113
+
114
+ req.write(body);
115
+ req.end();
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Returns delivery monitoring statistics.
121
+ */
122
+ getDeliveryStatus() {
123
+ const rate = this.deliveryStats.total > 0
124
+ ? (this.deliveryStats.success / this.deliveryStats.total) * 100
125
+ : 0;
126
+
127
+ return {
128
+ ...this.deliveryStats,
129
+ successRate: `${rate.toFixed(2)}%`
130
+ };
131
+ }
132
+ }
133
+
134
+ module.exports = WebhookService;
@@ -1,8 +1,165 @@
1
- class ApiError extends Error {
2
- constructor(message, status = 400) {
3
- super(message);
4
- this.status = status;
5
- }
6
- }
7
-
8
- module.exports = ApiError;
1
+ // packages/auth/src/utils/api-error.js
2
+ /**
3
+ * Standardized, machine-readable auth errors.
4
+ *
5
+ * Every error thrown by the library carries:
6
+ * - code : stable, namespaced error code (AUTH_*)
7
+ * - message : human-readable description
8
+ * - status : HTTP status code
9
+ * - details : optional structured payload (e.g. field-level messages)
10
+ *
11
+ * Frontends can switch on `code` instead of parsing prose strings.
12
+ */
13
+ class ApiError extends Error {
14
+ constructor(message, code = 'AUTH_INTERNAL', status = 400, details = undefined) {
15
+ super(message);
16
+ this.name = 'ApiError';
17
+ this.code = code;
18
+ this.status = status;
19
+ if (details !== undefined) this.details = details;
20
+
21
+ // keep V8 stack trace behaviour intact
22
+ Error.captureStackTrace(this, this.constructor);
23
+ }
24
+
25
+ // ---- factory helpers -------------------------------------------------
26
+ static unauthorized(message = 'Authentication required', details) {
27
+ return new ApiError(message, 'AUTH_UNAUTHORIZED', 401, details);
28
+ }
29
+
30
+ static forbidden(message = 'Insufficient permissions', details) {
31
+ return new ApiError(message, 'AUTH_FORBIDDEN', 403, details);
32
+ }
33
+
34
+ static notFound(message = 'Resource not found', details) {
35
+ return new ApiError(message, 'AUTH_NOT_FOUND', 404, details);
36
+ }
37
+
38
+ static validation(message = 'Invalid request data', details) {
39
+ return new ApiError(message, 'AUTH_VALIDATION', 400, details);
40
+ }
41
+
42
+ static badRequest(message = 'Bad request', details) {
43
+ return new ApiError(message, 'AUTH_BAD_REQUEST', 400, details);
44
+ }
45
+
46
+ static conflict(message = 'Resource already exists', details) {
47
+ return new ApiError(message, 'AUTH_CONFLICT', 409, details);
48
+ }
49
+
50
+ static tooManyRequests(message = 'Too many requests, please try later', details) {
51
+ return new ApiError(message, 'AUTH_RATE_LIMITED', 429, details);
52
+ }
53
+
54
+ static accountLocked(message = 'Account locked. Try again later.', details) {
55
+ return new ApiError(message, 'AUTH_ACCOUNT_LOCKED', 403, details);
56
+ }
57
+
58
+ static invalidCredentials(message = 'Invalid credentials', details) {
59
+ return new ApiError(message, 'AUTH_INVALID_CREDENTIALS', 401, details);
60
+ }
61
+
62
+ static invalidToken(message = 'Invalid or expired token', details) {
63
+ return new ApiError(message, 'AUTH_INVALID_TOKEN', 401, details);
64
+ }
65
+
66
+ static expiredToken(message = 'Token expired', details) {
67
+ return new ApiError(message, 'AUTH_EXPIRED_TOKEN', 401, details);
68
+ }
69
+
70
+ static emailNotVerified(message = 'Email not verified', details) {
71
+ return new ApiError(message, 'AUTH_EMAIL_NOT_VERIFIED', 403, details);
72
+ }
73
+
74
+ static twoFactorRequired(message = 'Two-factor authentication required', details) {
75
+ return new ApiError(message, 'AUTH_2FA_REQUIRED', 403, details);
76
+ }
77
+
78
+ static twoFactorFailed(message = 'Invalid 2FA code', details) {
79
+ return new ApiError(message, 'AUTH_2FA_FAILED', 401, details);
80
+ }
81
+
82
+ static passwordPolicy(message = 'Password policy violation', details) {
83
+ return new ApiError(message, 'AUTH_PASSWORD_POLICY', 400, details);
84
+ }
85
+
86
+ static passwordReuse(message = 'Cannot reuse previous passwords', details) {
87
+ return new ApiError(message, 'AUTH_PASSWORD_REUSE', 400, details);
88
+ }
89
+
90
+ static smtpDisabled(message = 'Email delivery is not configured', details) {
91
+ return new ApiError(message, 'AUTH_SMTP_DISABLED', 503, details);
92
+ }
93
+
94
+ static socialAuthFailed(message = 'Social login failed', details) {
95
+ return new ApiError(message, 'AUTH_SOCIAL_FAILED', 400, details);
96
+ }
97
+
98
+ static internal(message = 'Internal server error', details) {
99
+ return new ApiError(message, 'AUTH_INTERNAL', 500, details);
100
+ }
101
+
102
+ // ---- predicates -------------------------------------------------------
103
+ /** true when the thrown value is one of our ApiError instances */
104
+ static isApiError(err) {
105
+ return err instanceof ApiError;
106
+ }
107
+
108
+ /** Coerce any thrown value into an ApiError (never throws) */
109
+ static normalize(err) {
110
+ if (err instanceof ApiError) return err;
111
+
112
+ if (err && typeof err === 'object') {
113
+ // mongoose ValidationError
114
+ if (err.name === 'ValidationError') {
115
+ const details = {};
116
+ for (const [k, v] of Object.entries(err.errors || {})) {
117
+ details[k] = v.message;
118
+ }
119
+ return new ApiError(
120
+ 'Validation failed',
121
+ 'AUTH_VALIDATION',
122
+ 400,
123
+ Object.keys(details).length ? details : undefined
124
+ );
125
+ }
126
+ // jwt errors
127
+ if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
128
+ return new ApiError(
129
+ err.message || 'Invalid token',
130
+ err.name === 'TokenExpiredError' ? 'AUTH_EXPIRED_TOKEN' : 'AUTH_INVALID_TOKEN',
131
+ 401
132
+ );
133
+ }
134
+ // CastError (invalid ObjectId etc.)
135
+ if (err.name === 'CastError') {
136
+ return new ApiError('Invalid identifier', 'AUTH_INVALID_ID', 400);
137
+ }
138
+ // duplicate key
139
+ if (err.code === 11000) {
140
+ const field = err.keyValue ? Object.keys(err.keyValue)[0] : 'field';
141
+ return new ApiError(
142
+ `${field} already exists`,
143
+ 'AUTH_CONFLICT',
144
+ 409,
145
+ { field }
146
+ );
147
+ }
148
+ if (typeof err.status === 'number') {
149
+ return new ApiError(
150
+ err.message || 'Request failed',
151
+ err.code || 'AUTH_EXTERNAL',
152
+ err.status
153
+ );
154
+ }
155
+ }
156
+
157
+ if (err instanceof Error) {
158
+ return new ApiError(err.message, 'AUTH_INTERNAL', 500);
159
+ }
160
+
161
+ return new ApiError('Internal server error', 'AUTH_INTERNAL', 500);
162
+ }
163
+ }
164
+
165
+ module.exports = ApiError;
@@ -2,10 +2,12 @@
2
2
  const nodemailer = require('nodemailer');
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const ApiError = require('./api-error');
5
6
 
6
7
  class EmailService {
7
8
  constructor(config) {
8
9
  this.config = config;
10
+ this.baseUrl = (config && config.baseUrl) || '';
9
11
  this.smtpEnabled = !!(
10
12
  config.smtp &&
11
13
  config.smtp.host &&
@@ -20,10 +22,21 @@ class EmailService {
20
22
  this.templateDir = config.emailTemplateDir || path.join(process.cwd(), "templates/email");
21
23
  }
22
24
 
25
+ // Build a safe absolute URL from a base and a path, avoiding double slashes
26
+ // or `undefined/...` when baseUrl is missing.
27
+ joinUrl(base, urlPath) {
28
+ let b = (base == null ? '' : String(base)).trim();
29
+ if (b && b !== '/' && b.endsWith('/')) b = b.slice(0, -1);
30
+ let p = (urlPath == null ? '' : String(urlPath)).trim();
31
+ if (!p) p = '/';
32
+ if (!p.startsWith('/')) p = '/' + p;
33
+ return `${b}${p}`;
34
+ }
35
+
23
36
  // Reusable sender
24
37
  async send({ to, subject, html, text }) {
25
38
  if (!this.smtpEnabled) {
26
- return { sent: false, reason: 'smtp_disabled' };
39
+ throw ApiError.smtpDisabled('Email delivery is not configured. Configure smtp in auth config.');
27
40
  }
28
41
 
29
42
  try {
@@ -48,9 +61,10 @@ class EmailService {
48
61
 
49
62
  let content = fs.readFileSync(filePath, "utf8");
50
63
 
51
- // Replace {{variable}}
52
- for (const key of Object.keys(variables)) {
53
- content = content.replace(new RegExp(`{{${key}}}`, "g"), variables[key]);
64
+ // Replace {{variable}} using a safer approach
65
+ for (const [key, value] of Object.entries(variables)) {
66
+ const regex = new RegExp(`{{\\s*${key}\\s*}}`, "g");
67
+ content = content.replace(regex, value);
54
68
  }
55
69
 
56
70
  return content;
@@ -58,7 +72,7 @@ class EmailService {
58
72
 
59
73
  async sendVerificationEmail(user, token) {
60
74
  const verifyPath = this.config.routes?.verify || '/verify';
61
- const url = `${this.config.baseUrl}${verifyPath}?token=${token}`;
75
+ const url = `${this.joinUrl(this.baseUrl, verifyPath)}?token=${token}`;
62
76
 
63
77
  const html = this.loadTemplate("email-verification", {
64
78
  name: user.name || user.email,
@@ -75,7 +89,7 @@ class EmailService {
75
89
 
76
90
  async sendPasswordResetEmail(user, token) {
77
91
  const resetPath = this.config.routes?.resetPassword || '/reset-password';
78
- const url = `${this.config.baseUrl}${resetPath}?token=${token}`;
92
+ const url = `${this.joinUrl(this.baseUrl, resetPath)}?token=${token}`;
79
93
 
80
94
  const html = this.loadTemplate("password-reset", {
81
95
  name: user.name || user.email,
@@ -91,21 +105,25 @@ class EmailService {
91
105
  }
92
106
 
93
107
  async sendLoginAlert(user, info) {
108
+ info = info || {};
109
+ const ip = info.ip || 'unknown';
110
+ const userAgent = info.userAgent || 'unknown';
111
+
94
112
  const html = this.loadTemplate("login-alert", {
95
113
  name: user.name || user.email,
96
- ip: info.ip,
97
- agent: info.userAgent
114
+ ip,
115
+ agent: userAgent
98
116
  }) || `
99
117
  New login detected.<br>
100
- IP: ${info.ip}<br>
101
- User Agent: ${info.userAgent}
118
+ IP: ${ip}<br>
119
+ User Agent: ${userAgent}
102
120
  `;
103
121
 
104
122
  return this.send({
105
123
  to: user.email,
106
124
  subject: "New Login Detected",
107
125
  html,
108
- text: `New login from IP ${info.ip}`
126
+ text: `New login from IP ${ip}`
109
127
  });
110
128
  }
111
129
 
@@ -123,6 +141,34 @@ class EmailService {
123
141
  });
124
142
  }
125
143
 
144
+ async sendMagicLink(user, url) {
145
+ const html = this.loadTemplate("magic-link", {
146
+ name: user.name || user.email,
147
+ url
148
+ }) || `Sign in with this magic link: <a href="${url}">${url}</a><br>This link expires in 15 minutes.`;
149
+
150
+ return this.send({
151
+ to: user.email,
152
+ subject: "Your Magic Sign-In Link",
153
+ html,
154
+ text: `Sign in here: ${url} (expires in 15 minutes)`
155
+ });
156
+ }
157
+
158
+ async sendMagicLink(user, url) {
159
+ const html = this.loadTemplate("magic-link", {
160
+ name: user.name || user.email,
161
+ url
162
+ }) || `Sign in with this magic link: <a href="${url}">${url}</a><br>This link expires in 15 minutes.`;
163
+
164
+ return this.send({
165
+ to: user.email,
166
+ subject: "Your Magic Sign-In Link",
167
+ html,
168
+ text: `Sign in here: ${url} (expires in 15 minutes)`
169
+ });
170
+ }
171
+
126
172
  async sendAccountLockEmail(user) {
127
173
  const html = this.loadTemplate("account-locked", {
128
174
  name: user.name || user.email