stackkit 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +4 -0
  2. package/bin/stackkit.js +8 -5
  3. package/dist/cli/add.js +4 -9
  4. package/dist/cli/create.js +4 -9
  5. package/dist/cli/doctor.js +11 -32
  6. package/dist/lib/constants.js +3 -4
  7. package/dist/lib/conversion/js-conversion.js +20 -16
  8. package/dist/lib/discovery/installed-detection.js +28 -38
  9. package/dist/lib/discovery/module-discovery.d.ts +0 -15
  10. package/dist/lib/discovery/module-discovery.js +15 -50
  11. package/dist/lib/framework/framework-utils.d.ts +4 -5
  12. package/dist/lib/framework/framework-utils.js +38 -49
  13. package/dist/lib/fs/files.js +1 -1
  14. package/dist/lib/generation/code-generator.d.ts +13 -19
  15. package/dist/lib/generation/code-generator.js +159 -175
  16. package/dist/lib/generation/generator-utils.js +3 -15
  17. package/dist/lib/project/detect.js +11 -19
  18. package/dist/lib/utils/fs-helpers.d.ts +1 -1
  19. package/modules/auth/authjs/generator.json +16 -16
  20. package/modules/auth/better-auth/files/express/middlewares/authorize.ts +178 -40
  21. package/modules/auth/better-auth/files/express/modules/auth/auth.controller.ts +264 -0
  22. package/modules/auth/better-auth/files/express/modules/auth/auth.route.ts +27 -0
  23. package/modules/auth/better-auth/files/express/modules/auth/auth.service.ts +537 -0
  24. package/modules/auth/better-auth/files/express/modules/auth/auth.type.ts +33 -0
  25. package/modules/auth/better-auth/files/express/templates/google-redirect.ejs +91 -0
  26. package/modules/auth/better-auth/files/express/templates/otp.ejs +87 -0
  27. package/modules/auth/better-auth/files/express/types/express.d.ts +6 -8
  28. package/modules/auth/better-auth/files/express/utils/cookie.ts +19 -0
  29. package/modules/auth/better-auth/files/express/utils/jwt.ts +34 -0
  30. package/modules/auth/better-auth/files/express/utils/token.ts +66 -0
  31. package/modules/auth/better-auth/files/nextjs/api/auth/[...all]/route.ts +1 -1
  32. package/modules/auth/better-auth/files/nextjs/lib/auth/auth-guards.ts +11 -1
  33. package/modules/auth/better-auth/files/nextjs/templates/email-otp.tsx +74 -0
  34. package/modules/auth/better-auth/files/shared/config/env.ts +117 -0
  35. package/modules/auth/better-auth/files/shared/lib/auth-client.ts +1 -1
  36. package/modules/auth/better-auth/files/shared/lib/auth.ts +167 -79
  37. package/modules/auth/better-auth/files/shared/mongoose/auth/constants.ts +11 -0
  38. package/modules/auth/better-auth/files/shared/mongoose/auth/helper.ts +51 -0
  39. package/modules/auth/better-auth/files/shared/prisma/schema.prisma +22 -11
  40. package/modules/auth/better-auth/files/shared/utils/email.ts +70 -0
  41. package/modules/auth/better-auth/generator.json +162 -80
  42. package/modules/database/mongoose/files/lib/mongoose.ts +28 -3
  43. package/modules/database/mongoose/generator.json +18 -18
  44. package/modules/database/prisma/generator.json +44 -44
  45. package/package.json +2 -2
  46. package/templates/express/env.example +3 -2
  47. package/templates/express/eslint.config.mjs +7 -0
  48. package/templates/express/node_modules/.bin/acorn +17 -0
  49. package/templates/express/node_modules/.bin/eslint +17 -0
  50. package/templates/express/node_modules/.bin/tsc +17 -0
  51. package/templates/express/node_modules/.bin/tsserver +17 -0
  52. package/templates/express/node_modules/.bin/tsx +17 -0
  53. package/templates/express/package.json +12 -6
  54. package/templates/express/src/app.ts +15 -7
  55. package/templates/express/src/config/cors.ts +8 -7
  56. package/templates/express/src/config/env.ts +28 -5
  57. package/templates/express/src/config/logger.ts +2 -2
  58. package/templates/express/src/config/rate-limit.ts +2 -2
  59. package/templates/express/src/modules/health/health.controller.ts +13 -11
  60. package/templates/express/src/routes/index.ts +1 -6
  61. package/templates/express/src/server.ts +12 -12
  62. package/templates/express/src/shared/errors/app-error.ts +16 -0
  63. package/templates/express/src/shared/middlewares/error.middleware.ts +154 -12
  64. package/templates/express/src/shared/middlewares/not-found.middleware.ts +2 -1
  65. package/templates/express/src/shared/utils/catch-async.ts +11 -0
  66. package/templates/express/src/shared/utils/pagination.ts +6 -1
  67. package/templates/express/src/shared/utils/send-response.ts +25 -0
  68. package/templates/nextjs/lib/env.ts +19 -8
  69. package/modules/auth/better-auth/files/shared/lib/email/email-service.ts +0 -33
  70. package/modules/auth/better-auth/files/shared/lib/email/email-templates.ts +0 -89
  71. package/templates/express/eslint.config.cjs +0 -42
  72. package/templates/express/src/config/helmet.ts +0 -5
  73. package/templates/express/src/modules/health/health.service.ts +0 -6
  74. package/templates/express/src/shared/errors/error-codes.ts +0 -9
  75. package/templates/express/src/shared/logger/logger.ts +0 -20
  76. package/templates/express/src/shared/utils/async-handler.ts +0 -9
  77. package/templates/express/src/shared/utils/response.ts +0 -9
@@ -0,0 +1,537 @@
1
+ import status from "http-status";
2
+ import { JwtPayload } from "jsonwebtoken";
3
+ import { envVars } from "../../config/env";
4
+ {{#if database == "prisma"}}
5
+ import { prisma } from "../../database/prisma";
6
+ {{/if}}
7
+ {{#if database == "mongoose"}}
8
+ import {
9
+ deleteAuthUserById,
10
+ getAuthCollections,
11
+ } from "./auth.helper";
12
+ {{/if}}
13
+ import { auth } from "../../lib/auth";
14
+ import { AppError } from "../../shared/errors/app-error";
15
+ import { jwtUtils } from "../../shared/utils/jwt";
16
+ import { tokenUtils } from "../../shared/utils/token";
17
+ import {
18
+ IChangePasswordPayload,
19
+ ILoginUserPayload,
20
+ IRegisterUserPayload,
21
+ IRequestUser,
22
+ } from "./auth.type";
23
+
24
+ const registerUser = async (payload: IRegisterUserPayload) => {
25
+ const { name, email, password } = payload;
26
+
27
+ const data = await auth.api.signUpEmail({
28
+ body: {
29
+ name,
30
+ email,
31
+ password,
32
+ },
33
+ });
34
+
35
+ if (!data.user) {
36
+ throw new AppError(status.BAD_REQUEST, "Failed to register user");
37
+ }
38
+
39
+ try {
40
+ const accessToken = tokenUtils.getAccessToken({
41
+ userId: data.user.id,
42
+ role: data.user.role,
43
+ name: data.user.name,
44
+ email: data.user.email,
45
+ status: data.user.status,
46
+ isDeleted: data.user.isDeleted,
47
+ emailVerified: data.user.emailVerified,
48
+ });
49
+
50
+ const refreshToken = tokenUtils.getRefreshToken({
51
+ userId: data.user.id,
52
+ role: data.user.role,
53
+ name: data.user.name,
54
+ email: data.user.email,
55
+ status: data.user.status,
56
+ isDeleted: data.user.isDeleted,
57
+ emailVerified: data.user.emailVerified,
58
+ });
59
+
60
+ return {
61
+ ...data,
62
+ accessToken,
63
+ refreshToken,
64
+ user: data.user,
65
+ };
66
+ } catch (error) {
67
+ {{#if database == "prisma"}}
68
+ await prisma.user.delete({
69
+ where: {
70
+ id: data.user.id,
71
+ },
72
+ });
73
+ {{/if}}
74
+ {{#if database == "mongoose"}}
75
+ await deleteAuthUserById(data.user.id);
76
+ {{/if}}
77
+ throw error;
78
+ }
79
+
80
+ }
81
+
82
+ const loginUser = async (payload: ILoginUserPayload) => {
83
+ const { email, password } = payload;
84
+
85
+ const data = await auth.api.signInEmail({
86
+ body: {
87
+ email,
88
+ password,
89
+ }
90
+ })
91
+
92
+ if (data.user.status === "BLOCKED") {
93
+ throw new AppError(status.FORBIDDEN, "User is blocked");
94
+ }
95
+
96
+ if (data.user.isDeleted || data.user.status === "DELETED") {
97
+ throw new AppError(status.NOT_FOUND, "User is deleted");
98
+ }
99
+
100
+ const accessToken = tokenUtils.getAccessToken({
101
+ userId: data.user.id,
102
+ role: data.user.role,
103
+ name: data.user.name,
104
+ email: data.user.email,
105
+ status: data.user.status,
106
+ isDeleted: data.user.isDeleted,
107
+ emailVerified: data.user.emailVerified,
108
+ });
109
+
110
+ const refreshToken = tokenUtils.getRefreshToken({
111
+ userId: data.user.id,
112
+ role: data.user.role,
113
+ name: data.user.name,
114
+ email: data.user.email,
115
+ status: data.user.status,
116
+ isDeleted: data.user.isDeleted,
117
+ emailVerified: data.user.emailVerified,
118
+ });
119
+
120
+ return {
121
+ ...data,
122
+ accessToken,
123
+ refreshToken,
124
+ };
125
+ }
126
+
127
+ const getMe = async (user : IRequestUser) => {
128
+ {{#if database == "prisma"}}
129
+ const isUserExists = await prisma.user.findUnique({
130
+ where: {
131
+ id: user.id,
132
+ },
133
+ // Include other related models if needed
134
+ });
135
+ {{/if}}
136
+ {{#if database == "mongoose"}}
137
+ const { users } = await getAuthCollections();
138
+ const isUserExists = await users.findOne({ id: user.id });
139
+ {{/if}}
140
+
141
+ if (!isUserExists) {
142
+ throw new AppError(status.NOT_FOUND, "User not found");
143
+ }
144
+
145
+ return isUserExists;
146
+ }
147
+
148
+ const getNewToken = async (refreshToken : string, sessionToken : string) => {
149
+ {{#if database == "prisma"}}
150
+ const isSessionTokenExists = await prisma.session.findUnique({
151
+ where : {
152
+ token : sessionToken,
153
+ },
154
+ include : {
155
+ user : true,
156
+ }
157
+ })
158
+ {{/if}}
159
+ {{#if database == "mongoose"}}
160
+ const { sessions } = await getAuthCollections();
161
+
162
+ const isSessionTokenExists = await sessions.findOne({
163
+ token: sessionToken,
164
+ });
165
+ {{/if}}
166
+
167
+ if(!isSessionTokenExists){
168
+ throw new AppError(status.UNAUTHORIZED, "Invalid session token");
169
+ }
170
+
171
+ const verifiedRefreshToken = jwtUtils.verifyToken(refreshToken, envVars.REFRESH_TOKEN_SECRET)
172
+
173
+ if(!verifiedRefreshToken.success && verifiedRefreshToken.error){
174
+ throw new AppError(status.UNAUTHORIZED, "Invalid refresh token");
175
+ }
176
+
177
+ const data = verifiedRefreshToken.data as JwtPayload;
178
+
179
+ const newAccessToken = tokenUtils.getAccessToken({
180
+ userId: data.userId,
181
+ role: data.role,
182
+ name: data.name,
183
+ email: data.email,
184
+ status: data.status,
185
+ isDeleted: data.isDeleted,
186
+ emailVerified: data.emailVerified,
187
+ });
188
+
189
+ const newRefreshToken = tokenUtils.getRefreshToken({
190
+ userId: data.userId,
191
+ role: data.role,
192
+ name: data.name,
193
+ email: data.email,
194
+ status: data.status,
195
+ isDeleted: data.isDeleted,
196
+ emailVerified: data.emailVerified,
197
+ });
198
+
199
+ {{#if database == "prisma"}}
200
+ const { token } = await prisma.session.update({
201
+ where : {
202
+ token : sessionToken
203
+ },
204
+ data : {
205
+ token : sessionToken,
206
+ expiresAt: new Date(Date.now() + 60 * 60 * 60 * 24 * 1000),
207
+ updatedAt: new Date(),
208
+ }
209
+ })
210
+ {{/if}}
211
+ {{#if database == "mongoose"}}
212
+ const updatedSession = await sessions.findOneAndUpdate(
213
+ { token: sessionToken },
214
+ {
215
+ $set: {
216
+ token: sessionToken,
217
+ expiresAt: new Date(Date.now() + 60 * 60 * 60 * 24 * 1000),
218
+ updatedAt: new Date(),
219
+ },
220
+ },
221
+ {
222
+ returnDocument: "after",
223
+ },
224
+ );
225
+
226
+ if (!updatedSession) {
227
+ throw new AppError(status.UNAUTHORIZED, "Session not found");
228
+ }
229
+ const token = updatedSession.token;
230
+ {{/if}}
231
+
232
+ return {
233
+ accessToken : newAccessToken,
234
+ refreshToken : newRefreshToken,
235
+ sessionToken : token,
236
+ };
237
+ }
238
+
239
+ const changePassword = async (payload : IChangePasswordPayload, sessionToken : string) =>{
240
+ const session = await auth.api.getSession({
241
+ headers : new Headers({
242
+ Authorization : `Bearer ${sessionToken}`
243
+ })
244
+ })
245
+
246
+ if(!session){
247
+ throw new AppError(status.UNAUTHORIZED, "Invalid session token");
248
+ }
249
+
250
+ const {currentPassword, newPassword} = payload;
251
+
252
+ const result = await auth.api.changePassword({
253
+ body :{
254
+ currentPassword,
255
+ newPassword,
256
+ revokeOtherSessions: true,
257
+ },
258
+ headers : new Headers({
259
+ Authorization : `Bearer ${sessionToken}`
260
+ })
261
+ })
262
+
263
+ if(session.user.needPasswordChange){
264
+ {{#if database == "prisma"}}
265
+ await prisma.user.update({
266
+ where: {
267
+ id: session.user.id,
268
+ },
269
+ data: {
270
+ needPasswordChange: false,
271
+ }
272
+ })
273
+ {{/if}}
274
+ {{#if database == "mongoose"}}
275
+ const { users } = await getAuthCollections();
276
+ await users.updateOne(
277
+ {
278
+ id: session.user.id,
279
+ },
280
+ {
281
+ $set: {
282
+ needPasswordChange: false,
283
+ },
284
+ },
285
+ );
286
+ {{/if}}
287
+ }
288
+
289
+ const accessToken = tokenUtils.getAccessToken({
290
+ userId: session.user.id,
291
+ role: session.user.role,
292
+ name: session.user.name,
293
+ email: session.user.email,
294
+ status: session.user.status,
295
+ isDeleted: session.user.isDeleted,
296
+ emailVerified: session.user.emailVerified,
297
+ });
298
+
299
+ const refreshToken = tokenUtils.getRefreshToken({
300
+ userId: session.user.id,
301
+ role: session.user.role,
302
+ name: session.user.name,
303
+ email: session.user.email,
304
+ status: session.user.status,
305
+ isDeleted: session.user.isDeleted,
306
+ emailVerified: session.user.emailVerified,
307
+ });
308
+
309
+
310
+ return {
311
+ ...result,
312
+ accessToken,
313
+ refreshToken,
314
+ }
315
+ }
316
+
317
+ const logoutUser = async (sessionToken : string) => {
318
+ const result = await auth.api.signOut({
319
+ headers : new Headers({
320
+ Authorization : `Bearer ${sessionToken}`
321
+ })
322
+ })
323
+
324
+ return result;
325
+ }
326
+
327
+ const verifyEmail = async (email : string, otp : string) => {
328
+
329
+ const result = await auth.api.verifyEmailOTP({
330
+ body:{
331
+ email,
332
+ otp,
333
+ }
334
+ })
335
+
336
+ if(result.status && !result.user.emailVerified){
337
+ {{#if database == "prisma"}}
338
+ await prisma.user.update({
339
+ where : {
340
+ email,
341
+ },
342
+ data : {
343
+ emailVerified: true,
344
+ }
345
+ })
346
+ {{/if}}
347
+ {{#if database == "mongoose"}}
348
+ const { users } = await getAuthCollections();
349
+ await users.updateOne(
350
+ {
351
+ email,
352
+ },
353
+ {
354
+ $set: {
355
+ emailVerified: true,
356
+ },
357
+ },
358
+ );
359
+ {{/if}}
360
+ }
361
+ }
362
+
363
+ const forgetPassword = async (email : string) => {
364
+ {{#if database == "prisma"}}
365
+ const isUserExist = await prisma.user.findUnique({
366
+ where : {
367
+ email,
368
+ }
369
+ })
370
+ {{/if}}
371
+ {{#if database == "mongoose"}}
372
+ const { users } = await getAuthCollections();
373
+ const isUserExist = await users.findOne({
374
+ email,
375
+ });
376
+ {{/if}}
377
+
378
+ if(!isUserExist){
379
+ throw new AppError(status.NOT_FOUND, "User not found");
380
+ }
381
+
382
+ if(!isUserExist.emailVerified){
383
+ throw new AppError(status.BAD_REQUEST, "Email not verified");
384
+ }
385
+
386
+ if (isUserExist.isDeleted || isUserExist.status === "DELETED") {
387
+ throw new AppError(status.NOT_FOUND, "User not found");
388
+ }
389
+
390
+ await auth.api.requestPasswordResetEmailOTP({
391
+ body:{
392
+ email,
393
+ }
394
+ })
395
+ }
396
+
397
+ const resetPassword = async (email : string, otp : string, newPassword : string) => {
398
+ {{#if database == "prisma"}}
399
+ const isUserExist = await prisma.user.findUnique({
400
+ where : {
401
+ email,
402
+ }
403
+ })
404
+ {{/if}}
405
+ {{#if database == "mongoose"}}
406
+ const { users } = await getAuthCollections();
407
+ const isUserExist = await users.findOne({
408
+ email,
409
+ });
410
+ {{/if}}
411
+
412
+ if (!isUserExist) {
413
+ throw new AppError(status.NOT_FOUND, "User not found");
414
+ }
415
+
416
+ if (!isUserExist.emailVerified) {
417
+ throw new AppError(status.BAD_REQUEST, "Email not verified");
418
+ }
419
+
420
+ if (isUserExist.isDeleted || isUserExist.status === "DELETED") {
421
+ throw new AppError(status.NOT_FOUND, "User not found");
422
+ }
423
+
424
+ await auth.api.resetPasswordEmailOTP({
425
+ body:{
426
+ email,
427
+ otp,
428
+ password : newPassword,
429
+ }
430
+ })
431
+
432
+ {{#if database == "prisma"}}
433
+ if (isUserExist.needPasswordChange) {
434
+ await prisma.user.update({
435
+ where: {
436
+ email,
437
+ },
438
+ data: {
439
+ needPasswordChange: false,
440
+ }
441
+ })
442
+ }
443
+
444
+ await prisma.session.deleteMany({
445
+ where:{
446
+ userId : isUserExist.id,
447
+ }
448
+ })
449
+ {{/if}}
450
+ {{#if database == "mongoose"}}
451
+ if (isUserExist.needPasswordChange) {
452
+ await users.updateOne(
453
+ {
454
+ email,
455
+ },
456
+ {
457
+ $set: {
458
+ needPasswordChange: false,
459
+ },
460
+ },
461
+ );
462
+ }
463
+ const { sessions } = await getAuthCollections();
464
+ await sessions.deleteMany({
465
+ userId: isUserExist.id,
466
+ });
467
+ {{/if}}
468
+ }
469
+
470
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
471
+ const googleLoginSuccess = async (session : Record<string, any>) =>{
472
+ {{#if database == "prisma"}}
473
+ const isUserExists = await prisma.user.findUnique({
474
+ where: {
475
+ id: session.user.id,
476
+ },
477
+ });
478
+
479
+ if (!isUserExists) {
480
+ await prisma.user.create({
481
+ data: {
482
+ id: session.user.id,
483
+ name: session.user.name,
484
+ email: session.user.email,
485
+ },
486
+ });
487
+ }
488
+ {{/if}}
489
+ {{#if database == "mongoose"}}
490
+ const { users } = await getAuthCollections();
491
+ const isUserExists = await users.findOne({ id: session.user.id });
492
+
493
+ if (!isUserExists) {
494
+ await users.insertOne({
495
+ id: session.user.id,
496
+ name: session.user.name,
497
+ email: session.user.email,
498
+ role: "USER",
499
+ status: "ACTIVE",
500
+ needPasswordChange: false,
501
+ emailVerified: true,
502
+ isDeleted: false,
503
+ deletedAt: null,
504
+ });
505
+ }
506
+ {{/if}}
507
+
508
+ const accessToken = tokenUtils.getAccessToken({
509
+ userId: session.user.id,
510
+ role: session.user.role,
511
+ name: session.user.name,
512
+ });
513
+
514
+ const refreshToken = tokenUtils.getRefreshToken({
515
+ userId: session.user.id,
516
+ role: session.user.role,
517
+ name: session.user.name,
518
+ });
519
+
520
+ return {
521
+ accessToken,
522
+ refreshToken,
523
+ }
524
+ }
525
+
526
+ export const authService = {
527
+ registerUser: registerUser,
528
+ loginUser,
529
+ getMe,
530
+ getNewToken,
531
+ changePassword,
532
+ logoutUser,
533
+ verifyEmail,
534
+ forgetPassword,
535
+ resetPassword,
536
+ googleLoginSuccess,
537
+ };
@@ -0,0 +1,33 @@
1
+ {{#if database == "prisma"}}
2
+ import { Role } from "@prisma/client";
3
+
4
+ export interface IRequestUser {
5
+ id: string;
6
+ role: Role | string;
7
+ email: string;
8
+ }
9
+ {{/if}}
10
+ {{#if database == "mongoose"}}
11
+ import { Role } from './auth.constants';
12
+ export interface IRequestUser {
13
+ id: string;
14
+ role: typeof Role | string;
15
+ email: string;
16
+ }
17
+ {{/if}}
18
+
19
+ export interface ILoginUserPayload {
20
+ email: string;
21
+ password: string;
22
+ }
23
+
24
+ export interface IRegisterUserPayload {
25
+ name: string;
26
+ email: string;
27
+ password: string;
28
+ }
29
+
30
+ export interface IChangePasswordPayload {
31
+ currentPassword: string;
32
+ newPassword: string;
33
+ }
@@ -0,0 +1,91 @@
1
+ <!doctype html>
2
+ <html lang="en" xmlns="http://www.w3.org/1999/xhtml">
3
+
4
+ <head>
5
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
8
+ <title>
9
+ <%= appName || "Your App" %> - Continue with Google
10
+ </title>
11
+ </head>
12
+
13
+ <body style="margin:0; padding:0; background-color:#f3f4f6;">
14
+ <div style="display:none; max-height:0; overflow:hidden; opacity:0; color:transparent;">
15
+ Continue your Google sign-in securely.
16
+ </div>
17
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"
18
+ style="background-color:#f3f4f6; padding:24px 12px;">
19
+ <tr>
20
+ <td align="center">
21
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"
22
+ style="max-width:600px; background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; overflow:hidden;">
23
+ <tr>
24
+ <td style="padding:28px 24px 16px 24px; font-family:Arial, Helvetica, sans-serif; color:#111827;">
25
+ <h1 style="margin:0; font-size:22px; line-height:30px; font-weight:700;">Continue with
26
+ Google</h1>
27
+ <p style="margin:14px 0 0; font-size:15px; line-height:24px; color:#374151;">
28
+ Hi <%= userName || "there" %>,
29
+ </p>
30
+ <p style="margin:10px 0 0; font-size:15px; line-height:24px; color:#374151;">
31
+ We received a request to continue authentication with Google for your <strong>
32
+ <%= appName || "account" %>
33
+ </strong>.
34
+ </p>
35
+ </td>
36
+ </tr>
37
+
38
+ <tr>
39
+ <td style="padding:0 24px 24px 24px; font-family:Arial, Helvetica, sans-serif;">
40
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0">
41
+ <tr>
42
+ <td align="center" bgcolor="#111827" style="border-radius:8px;">
43
+ <a href="<%= redirectUrl %>" target="_blank"
44
+ style="display:inline-block; padding:12px 22px; font-size:15px; line-height:20px; font-weight:600; color:#ffffff; text-decoration:none;">
45
+ Continue Sign-in
46
+ </a>
47
+ </td>
48
+ </tr>
49
+ </table>
50
+
51
+ <p
52
+ style="margin:14px 0 0; font-size:13px; line-height:20px; color:#6b7280; word-break:break-word;">
53
+ Button not working? Copy and paste this URL into your browser:<br />
54
+ <a href="<%= redirectUrl %>" target="_blank"
55
+ style="color:#2563eb; text-decoration:underline;">
56
+ <%= redirectUrl %>
57
+ </a>
58
+ </p>
59
+ </td>
60
+ </tr>
61
+
62
+ <tr>
63
+ <td style="padding:0 24px 24px 24px; font-family:Arial, Helvetica, sans-serif; color:#4b5563;">
64
+ <p style="margin:0; font-size:14px; line-height:22px;">
65
+ If you didn’t request this, you can safely ignore this email. No changes will be made
66
+ unless you continue.
67
+ </p>
68
+ </td>
69
+ </tr>
70
+
71
+ <tr>
72
+ <td
73
+ style="padding:16px 24px 24px 24px; border-top:1px solid #e5e7eb; font-family:Arial, Helvetica, sans-serif; color:#6b7280;">
74
+ <p style="margin:0; font-size:12px; line-height:18px;">
75
+ Need help? Contact us at
76
+ <a href="mailto:<%= supportEmail || " support@example.com" %>" style="color:#2563eb;
77
+ text-decoration:underline;"><%= supportEmail || "support@example.com" %></a>.
78
+ </p>
79
+ <p style="margin:6px 0 0; font-size:12px; line-height:18px;">
80
+ © <%= year || new Date().getFullYear() %>
81
+ <%= appName || "Your App" %>. All rights reserved.
82
+ </p>
83
+ </td>
84
+ </tr>
85
+ </table>
86
+ </td>
87
+ </tr>
88
+ </table>
89
+ </body>
90
+
91
+ </html>