create-tigra 2.6.8 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-tigra.js +153 -1
- package/lib/patchers/email-verification.patcher.js +576 -0
- package/modules/email-verification/client/hooks/useVerification.ts +70 -0
- package/modules/email-verification/client/services/verification.service.ts +25 -0
- package/modules/email-verification/server/verification.controller.ts +28 -0
- package/modules/email-verification/server/verification.service.ts +190 -0
- package/package.json +5 -2
- package/template/client/src/features/auth/components/AuthInitializer.tsx +7 -1
- package/template/client/src/features/auth/hooks/useAuth.ts +10 -1
- package/template/client/src/features/auth/hooks/usePasswordReset.ts +57 -0
- package/template/client/src/features/auth/services/auth.service.ts +2 -2
- package/template/client/src/lib/constants/api-endpoints.ts +1 -1
- package/template/client/src/lib/constants/routes.ts +1 -1
- package/template/client/src/lib/utils/error.ts +4 -0
- package/template/server/.env.example +29 -0
- package/template/server/.env.example.production +22 -0
- package/template/server/package-lock.json +6823 -0
- package/template/server/package.json +1 -0
- package/template/server/src/config/env.ts +18 -1
- package/template/server/src/config/rate-limit.config.ts +8 -0
- package/template/server/src/libs/auth.ts +4 -1
- package/template/server/src/libs/email.ts +40 -0
- package/template/server/src/modules/auth/auth.controller.ts +27 -1
- package/template/server/src/modules/auth/auth.repo.ts +1 -0
- package/template/server/src/modules/auth/auth.routes.ts +24 -0
- package/template/server/src/modules/auth/auth.schemas.ts +18 -0
- package/template/server/src/modules/auth/auth.service.ts +136 -4
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Verification Module Patcher
|
|
3
|
+
*
|
|
4
|
+
* Uses ts-morph to structurally transform TypeScript files in a generated project.
|
|
5
|
+
* This approach is resilient to formatting changes and works on existing projects
|
|
6
|
+
* where developers may have modified the template code.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Project, SyntaxKind } from 'ts-morph';
|
|
10
|
+
import fs from 'fs-extra';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = path.dirname(__filename);
|
|
16
|
+
const ROOT_DIR = path.join(__dirname, '..', '..');
|
|
17
|
+
const MODULES_DIR = path.join(ROOT_DIR, 'modules', 'email-verification');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Apply the email verification module to a generated project.
|
|
21
|
+
* Copies module files and patches existing files via ts-morph AST transforms.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} targetDir - Path to the generated project root
|
|
24
|
+
*/
|
|
25
|
+
export async function applyEmailVerificationModule(targetDir) {
|
|
26
|
+
// A) Copy module files
|
|
27
|
+
await copyModuleFiles(targetDir);
|
|
28
|
+
|
|
29
|
+
// B) Patch server files via ts-morph
|
|
30
|
+
const project = new Project({
|
|
31
|
+
useInMemoryFileSystem: false,
|
|
32
|
+
skipAddingFilesFromTsConfig: true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
patchAuthRoutes(project, targetDir);
|
|
36
|
+
patchAuthSchemas(project, targetDir);
|
|
37
|
+
patchRateLimitConfig(project, targetDir);
|
|
38
|
+
patchAuthService(project, targetDir);
|
|
39
|
+
patchAuthRepo(project, targetDir);
|
|
40
|
+
|
|
41
|
+
// C) Patch client files via ts-morph
|
|
42
|
+
patchApiEndpoints(project, targetDir);
|
|
43
|
+
patchErrorCodes(project, targetDir);
|
|
44
|
+
patchUseAuthHook(targetDir);
|
|
45
|
+
|
|
46
|
+
// D) Patch Postman collection (JSON, not ts-morph)
|
|
47
|
+
await patchPostmanCollection(targetDir);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── File Copy ──────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
async function copyModuleFiles(targetDir) {
|
|
53
|
+
const copies = [
|
|
54
|
+
{
|
|
55
|
+
src: path.join(MODULES_DIR, 'server', 'verification.service.ts'),
|
|
56
|
+
dest: path.join(targetDir, 'server', 'src', 'modules', 'auth', 'verification.service.ts'),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
src: path.join(MODULES_DIR, 'server', 'verification.controller.ts'),
|
|
60
|
+
dest: path.join(targetDir, 'server', 'src', 'modules', 'auth', 'verification.controller.ts'),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
src: path.join(MODULES_DIR, 'client', 'services', 'verification.service.ts'),
|
|
64
|
+
dest: path.join(targetDir, 'client', 'src', 'features', 'auth', 'services', 'verification.service.ts'),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
src: path.join(MODULES_DIR, 'client', 'hooks', 'useVerification.ts'),
|
|
68
|
+
dest: path.join(targetDir, 'client', 'src', 'features', 'auth', 'hooks', 'useVerification.ts'),
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
for (const { src, dest } of copies) {
|
|
73
|
+
await fs.ensureDir(path.dirname(dest));
|
|
74
|
+
await fs.copy(src, dest);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Server Patches ─────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Patch 1: auth.routes.ts
|
|
82
|
+
* - Add imports for verifyAccountSchema and verificationController
|
|
83
|
+
* - Append two route registrations to the authRoutes function body
|
|
84
|
+
*/
|
|
85
|
+
function patchAuthRoutes(project, targetDir) {
|
|
86
|
+
const filePath = path.join(targetDir, 'server', 'src', 'modules', 'auth', 'auth.routes.ts');
|
|
87
|
+
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
88
|
+
|
|
89
|
+
// Add imports for verification schemas — merge into existing schemas import if present
|
|
90
|
+
const existingSchemasImport = sourceFile.getImportDeclaration(
|
|
91
|
+
(decl) => decl.getModuleSpecifierValue() === './auth.schemas.js',
|
|
92
|
+
);
|
|
93
|
+
if (existingSchemasImport) {
|
|
94
|
+
const existing = existingSchemasImport.getNamedImports().map((n) => n.getName());
|
|
95
|
+
if (!existing.includes('sendVerificationSchema')) {
|
|
96
|
+
existingSchemasImport.addNamedImport('sendVerificationSchema');
|
|
97
|
+
}
|
|
98
|
+
if (!existing.includes('verifyAccountSchema')) {
|
|
99
|
+
existingSchemasImport.addNamedImport('verifyAccountSchema');
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
sourceFile.addImportDeclaration({
|
|
103
|
+
namedImports: ['sendVerificationSchema', 'verifyAccountSchema'],
|
|
104
|
+
moduleSpecifier: './auth.schemas.js',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Add import for verification controller
|
|
109
|
+
const existingVerifImport = sourceFile.getImportDeclaration(
|
|
110
|
+
(decl) => decl.getModuleSpecifierValue() === './verification.controller.js',
|
|
111
|
+
);
|
|
112
|
+
if (!existingVerifImport) {
|
|
113
|
+
sourceFile.addImportDeclaration({
|
|
114
|
+
namespaceImport: 'verificationController',
|
|
115
|
+
moduleSpecifier: './verification.controller.js',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Find the authRoutes function and append route statements
|
|
120
|
+
const authRoutesFn = sourceFile.getFunction('authRoutes');
|
|
121
|
+
if (!authRoutesFn) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
'Could not find function "authRoutes" in auth.routes.ts — file may have been modified',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
authRoutesFn.addStatements(`
|
|
128
|
+
// Send verification email (resend) - public, accepts email in body
|
|
129
|
+
fastify.post('/auth/send-verification', {
|
|
130
|
+
schema: {
|
|
131
|
+
body: sendVerificationSchema,
|
|
132
|
+
},
|
|
133
|
+
config: {
|
|
134
|
+
rateLimit: RATE_LIMITS.AUTH_SEND_VERIFICATION,
|
|
135
|
+
},
|
|
136
|
+
handler: verificationController.sendVerification,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Verify account with token
|
|
140
|
+
fastify.post('/auth/verify-account', {
|
|
141
|
+
schema: {
|
|
142
|
+
body: verifyAccountSchema,
|
|
143
|
+
},
|
|
144
|
+
config: {
|
|
145
|
+
rateLimit: RATE_LIMITS.AUTH_VERIFY_ACCOUNT,
|
|
146
|
+
},
|
|
147
|
+
handler: verificationController.verifyAccount,
|
|
148
|
+
});`);
|
|
149
|
+
|
|
150
|
+
sourceFile.saveSync();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Patch 2: auth.schemas.ts
|
|
155
|
+
* - Append verifyAccountSchema and VerifyAccountInput type
|
|
156
|
+
*/
|
|
157
|
+
function patchAuthSchemas(project, targetDir) {
|
|
158
|
+
const filePath = path.join(targetDir, 'server', 'src', 'modules', 'auth', 'auth.schemas.ts');
|
|
159
|
+
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
160
|
+
|
|
161
|
+
// Check if already patched
|
|
162
|
+
const existing = sourceFile.getVariableDeclaration('verifyAccountSchema');
|
|
163
|
+
if (existing) return;
|
|
164
|
+
|
|
165
|
+
sourceFile.addStatements(`
|
|
166
|
+
export const sendVerificationSchema = z.object({
|
|
167
|
+
email: z.string().email('Invalid email address').toLowerCase().trim(),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export type SendVerificationInput = z.infer<typeof sendVerificationSchema>;
|
|
171
|
+
|
|
172
|
+
export const verifyAccountSchema = z.object({
|
|
173
|
+
token: z.string().min(1, 'Token is required'),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
export type VerifyAccountInput = z.infer<typeof verifyAccountSchema>;`);
|
|
177
|
+
|
|
178
|
+
sourceFile.saveSync();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Patch 3: rate-limit.config.ts
|
|
183
|
+
* - Add AUTH_SEND_VERIFICATION and AUTH_VERIFY_ACCOUNT entries
|
|
184
|
+
* after AUTH_RESET_PASSWORD in the RATE_LIMITS object
|
|
185
|
+
*/
|
|
186
|
+
function patchRateLimitConfig(project, targetDir) {
|
|
187
|
+
const filePath = path.join(targetDir, 'server', 'src', 'config', 'rate-limit.config.ts');
|
|
188
|
+
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
189
|
+
|
|
190
|
+
const rateLimitsVar = sourceFile.getVariableDeclaration('RATE_LIMITS');
|
|
191
|
+
if (!rateLimitsVar) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
'Could not find variable "RATE_LIMITS" in rate-limit.config.ts — file may have been modified',
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Handle both `{ ... }` and `{ ... } as const` patterns
|
|
198
|
+
let objectLiteral = rateLimitsVar.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
199
|
+
if (!objectLiteral) {
|
|
200
|
+
// Check for `as const` assertion: the initializer is an AsExpression wrapping the object literal
|
|
201
|
+
const asExpr = rateLimitsVar.getInitializerIfKind(SyntaxKind.AsExpression);
|
|
202
|
+
if (asExpr) {
|
|
203
|
+
objectLiteral = asExpr.getExpressionIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!objectLiteral) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
'RATE_LIMITS is not an object literal in rate-limit.config.ts — file may have been modified',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check if already patched
|
|
213
|
+
if (objectLiteral.getProperty('AUTH_SEND_VERIFICATION')) return;
|
|
214
|
+
|
|
215
|
+
// Use text manipulation to insert after AUTH_RESET_PASSWORD block
|
|
216
|
+
// ts-morph's insertPropertyAssignment has quirks with index positioning,
|
|
217
|
+
// so we use the more reliable addPropertyAssignment which appends at end
|
|
218
|
+
// (position within the object doesn't matter for a config map)
|
|
219
|
+
objectLiteral.addPropertyAssignment({
|
|
220
|
+
name: 'AUTH_SEND_VERIFICATION',
|
|
221
|
+
initializer: `{\n max: applyMultiplier(3),\n timeWindow: '15 minutes',\n }`,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
objectLiteral.addPropertyAssignment({
|
|
225
|
+
name: 'AUTH_VERIFY_ACCOUNT',
|
|
226
|
+
initializer: `{\n max: applyMultiplier(10),\n timeWindow: '15 minutes',\n }`,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
sourceFile.saveSync();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Patch 4: auth.service.ts
|
|
234
|
+
* - Export sanitizeUser function, SanitizedUser interface, AuthResult interface
|
|
235
|
+
* so verification.service.ts can import them
|
|
236
|
+
* - Add import for sendVerification and call it during register (auto-send on signup)
|
|
237
|
+
*/
|
|
238
|
+
function patchAuthService(project, targetDir) {
|
|
239
|
+
const filePath = path.join(targetDir, 'server', 'src', 'modules', 'auth', 'auth.service.ts');
|
|
240
|
+
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
241
|
+
|
|
242
|
+
// Export sanitizeUser function
|
|
243
|
+
const sanitizeFn = sourceFile.getFunction('sanitizeUser');
|
|
244
|
+
if (!sanitizeFn) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
'Could not find function "sanitizeUser" in auth.service.ts — file may have been modified',
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (!sanitizeFn.isExported()) {
|
|
250
|
+
sanitizeFn.setIsExported(true);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Export SanitizedUser interface
|
|
254
|
+
const sanitizedUserIface = sourceFile.getInterface('SanitizedUser');
|
|
255
|
+
if (!sanitizedUserIface) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
'Could not find interface "SanitizedUser" in auth.service.ts — file may have been modified',
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (!sanitizedUserIface.isExported()) {
|
|
261
|
+
sanitizedUserIface.setIsExported(true);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Export AuthResult interface
|
|
265
|
+
const authResultIface = sourceFile.getInterface('AuthResult');
|
|
266
|
+
if (!authResultIface) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
'Could not find interface "AuthResult" in auth.service.ts — file may have been modified',
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (!authResultIface.isExported()) {
|
|
272
|
+
authResultIface.setIsExported(true);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Add import for sendVerification from verification.service (if not already present)
|
|
276
|
+
const existingVerifImport = sourceFile.getImportDeclaration(
|
|
277
|
+
(decl) => decl.getModuleSpecifierValue() === './verification.service.js',
|
|
278
|
+
);
|
|
279
|
+
if (!existingVerifImport) {
|
|
280
|
+
sourceFile.addImportDeclaration({
|
|
281
|
+
namedImports: ['sendVerification'],
|
|
282
|
+
moduleSpecifier: './verification.service.js',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Inject sendVerification call in the register function's verification branch.
|
|
287
|
+
// We do this via text replacement on the file content since ts-morph AST traversal
|
|
288
|
+
// of if-statement bodies to find a specific return pattern is fragile.
|
|
289
|
+
sourceFile.saveSync();
|
|
290
|
+
|
|
291
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
292
|
+
let patched = content;
|
|
293
|
+
|
|
294
|
+
// Patch 1: Auto-send verification email on registration
|
|
295
|
+
const registerAnchor = 'if (!isActive) {\n return {\n user: sanitizeUser(user),\n requiresVerification: true,\n };\n }';
|
|
296
|
+
if (patched.includes(registerAnchor) && !patched.includes('sendVerification(input.email)')) {
|
|
297
|
+
const registerReplacement = `if (!isActive) {\n // Auto-send verification email on registration (best-effort, don't block registration)\n sendVerification(input.email).catch(() => {});\n\n return {\n user: sanitizeUser(user),\n requiresVerification: true,\n };\n }`;
|
|
298
|
+
patched = patched.replace(registerAnchor, registerReplacement);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Patch 2: Auto-resend verification email when inactive user tries to log in
|
|
302
|
+
const loginAnchor = " if (!user.isActive) {\n throw new ForbiddenError('Account is not activated. Please verify your account.', 'ACCOUNT_NOT_ACTIVE');\n }";
|
|
303
|
+
if (patched.includes(loginAnchor) && !patched.includes('sendVerification(user.email)')) {
|
|
304
|
+
const loginReplacement = " if (!user.isActive) {\n // Auto-resend verification email so user gets a fresh link\n sendVerification(user.email).catch(() => {});\n\n throw new ForbiddenError('Account is not activated. Please verify your account.', 'ACCOUNT_NOT_ACTIVE');\n }";
|
|
305
|
+
patched = patched.replace(loginAnchor, loginReplacement);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (patched !== content) {
|
|
309
|
+
fs.writeFileSync(filePath, patched, 'utf-8');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Patch 5: auth.repo.ts
|
|
315
|
+
* - Add activateUser function at end of file
|
|
316
|
+
*/
|
|
317
|
+
function patchAuthRepo(project, targetDir) {
|
|
318
|
+
const filePath = path.join(targetDir, 'server', 'src', 'modules', 'auth', 'auth.repo.ts');
|
|
319
|
+
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
320
|
+
|
|
321
|
+
// Check if already patched
|
|
322
|
+
const existing = sourceFile.getFunction('activateUser');
|
|
323
|
+
if (existing) return;
|
|
324
|
+
|
|
325
|
+
sourceFile.addFunction({
|
|
326
|
+
name: 'activateUser',
|
|
327
|
+
isExported: true,
|
|
328
|
+
isAsync: true,
|
|
329
|
+
parameters: [{ name: 'userId', type: 'string' }],
|
|
330
|
+
returnType: 'Promise<void>',
|
|
331
|
+
statements: `await prisma.user.update({
|
|
332
|
+
where: { id: userId },
|
|
333
|
+
data: { isActive: true },
|
|
334
|
+
});`,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
sourceFile.saveSync();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── Client Patches ─────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Patch 6: api-endpoints.ts
|
|
344
|
+
* - Add SEND_VERIFICATION and VERIFY_ACCOUNT to the AUTH object
|
|
345
|
+
*/
|
|
346
|
+
function patchApiEndpoints(project, targetDir) {
|
|
347
|
+
const filePath = path.join(targetDir, 'client', 'src', 'lib', 'constants', 'api-endpoints.ts');
|
|
348
|
+
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
349
|
+
|
|
350
|
+
const apiEndpointsVar = sourceFile.getVariableDeclaration('API_ENDPOINTS');
|
|
351
|
+
if (!apiEndpointsVar) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
'Could not find variable "API_ENDPOINTS" in api-endpoints.ts — file may have been modified',
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let outerObject = apiEndpointsVar.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
358
|
+
if (!outerObject) {
|
|
359
|
+
const asExpr = apiEndpointsVar.getInitializerIfKind(SyntaxKind.AsExpression);
|
|
360
|
+
if (asExpr) {
|
|
361
|
+
outerObject = asExpr.getExpressionIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (!outerObject) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
'API_ENDPOINTS is not an object literal in api-endpoints.ts — file may have been modified',
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const authProp = outerObject.getProperty('AUTH');
|
|
371
|
+
if (!authProp) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
'Could not find AUTH property in API_ENDPOINTS — file may have been modified',
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Get the AUTH object's initializer
|
|
378
|
+
const authInitializer = authProp.getChildrenOfKind(SyntaxKind.ObjectLiteralExpression)[0];
|
|
379
|
+
if (!authInitializer) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
'AUTH property is not an object literal — file may have been modified',
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Check if already patched
|
|
386
|
+
if (authInitializer.getProperty('SEND_VERIFICATION')) return;
|
|
387
|
+
|
|
388
|
+
authInitializer.addPropertyAssignment({
|
|
389
|
+
name: 'SEND_VERIFICATION',
|
|
390
|
+
initializer: `'/auth/send-verification'`,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
authInitializer.addPropertyAssignment({
|
|
394
|
+
name: 'VERIFY_ACCOUNT',
|
|
395
|
+
initializer: `'/auth/verify-account'`,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
sourceFile.saveSync();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Patch 7: error.ts
|
|
403
|
+
* - Add ALREADY_VERIFIED and INVALID_VERIFICATION_TOKEN to ERROR_CODES
|
|
404
|
+
*/
|
|
405
|
+
function patchErrorCodes(project, targetDir) {
|
|
406
|
+
const filePath = path.join(targetDir, 'client', 'src', 'lib', 'utils', 'error.ts');
|
|
407
|
+
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
408
|
+
|
|
409
|
+
const errorCodesVar = sourceFile.getVariableDeclaration('ERROR_CODES');
|
|
410
|
+
if (!errorCodesVar) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
'Could not find variable "ERROR_CODES" in error.ts — file may have been modified',
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let errorCodesObject = errorCodesVar.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
417
|
+
if (!errorCodesObject) {
|
|
418
|
+
const asExpr = errorCodesVar.getInitializerIfKind(SyntaxKind.AsExpression);
|
|
419
|
+
if (asExpr) {
|
|
420
|
+
errorCodesObject = asExpr.getExpressionIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (!errorCodesObject) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
'ERROR_CODES is not an object literal in error.ts — file may have been modified',
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Check if already patched
|
|
430
|
+
if (errorCodesObject.getProperty('ALREADY_VERIFIED')) return;
|
|
431
|
+
|
|
432
|
+
errorCodesObject.addPropertyAssignment({
|
|
433
|
+
name: 'ALREADY_VERIFIED',
|
|
434
|
+
initializer: `'ALREADY_VERIFIED'`,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
errorCodesObject.addPropertyAssignment({
|
|
438
|
+
name: 'INVALID_VERIFICATION_TOKEN',
|
|
439
|
+
initializer: `'INVALID_VERIFICATION_TOKEN'`,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
sourceFile.saveSync();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Patch 8: useAuth.ts (client)
|
|
447
|
+
* - Redirect to /verify-account when login fails with ACCOUNT_NOT_ACTIVE
|
|
448
|
+
* (the server auto-resends the verification email on this error)
|
|
449
|
+
*/
|
|
450
|
+
function patchUseAuthHook(targetDir) {
|
|
451
|
+
const filePath = path.join(targetDir, 'client', 'src', 'features', 'auth', 'hooks', 'useAuth.ts');
|
|
452
|
+
if (!fs.pathExistsSync(filePath)) return;
|
|
453
|
+
|
|
454
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
455
|
+
|
|
456
|
+
// Check if already patched — look for the specific replacement text
|
|
457
|
+
if (content.includes('Check your email for a verification link')) return;
|
|
458
|
+
|
|
459
|
+
// Find the ACCOUNT_NOT_ACTIVE error handler and add redirect + update message.
|
|
460
|
+
// Match regardless of exact indentation by searching for the key content.
|
|
461
|
+
const anchorText = "toast.error('Your account is not yet activated. Please verify your account to continue.');";
|
|
462
|
+
|
|
463
|
+
if (!content.includes(anchorText)) return; // File may have been modified
|
|
464
|
+
|
|
465
|
+
// Replace the toast message and add router.push right after it
|
|
466
|
+
const patched = content.replace(
|
|
467
|
+
anchorText,
|
|
468
|
+
"toast.error('Your account is not yet activated. Check your email for a verification link.');\n router.push(ROUTES.VERIFY_ACCOUNT);",
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
fs.writeFileSync(filePath, patched, 'utf-8');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ─── Postman Patch ──────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Patch 8: postman/collection.json
|
|
478
|
+
* - Add "Send Verification" and "Verify Account" requests to the Auth folder
|
|
479
|
+
*/
|
|
480
|
+
async function patchPostmanCollection(targetDir) {
|
|
481
|
+
const filePath = path.join(targetDir, 'server', 'postman', 'collection.json');
|
|
482
|
+
if (!(await fs.pathExists(filePath))) return;
|
|
483
|
+
|
|
484
|
+
const collection = await fs.readJson(filePath);
|
|
485
|
+
|
|
486
|
+
// Find the Auth folder (first item with name "Auth")
|
|
487
|
+
const authFolder = collection.item?.find((folder) => folder.name === 'Auth');
|
|
488
|
+
if (!authFolder) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
'Could not find "Auth" folder in Postman collection — file may have been modified',
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Check if already patched
|
|
495
|
+
const alreadyPatched = authFolder.item?.some(
|
|
496
|
+
(req) => req.name === 'Send Verification',
|
|
497
|
+
);
|
|
498
|
+
if (alreadyPatched) return;
|
|
499
|
+
|
|
500
|
+
// Add Send Verification request (public — accepts email in body)
|
|
501
|
+
authFolder.item.push({
|
|
502
|
+
name: 'Send Verification',
|
|
503
|
+
request: {
|
|
504
|
+
auth: {
|
|
505
|
+
type: 'noauth',
|
|
506
|
+
},
|
|
507
|
+
method: 'POST',
|
|
508
|
+
header: [
|
|
509
|
+
{
|
|
510
|
+
key: 'Content-Type',
|
|
511
|
+
value: 'application/json',
|
|
512
|
+
},
|
|
513
|
+
],
|
|
514
|
+
body: {
|
|
515
|
+
mode: 'raw',
|
|
516
|
+
raw: '{\n "email": "john.doe@example.com"\n}',
|
|
517
|
+
},
|
|
518
|
+
url: {
|
|
519
|
+
raw: '{{baseUrl}}/auth/send-verification',
|
|
520
|
+
host: ['{{baseUrl}}'],
|
|
521
|
+
path: ['auth', 'send-verification'],
|
|
522
|
+
},
|
|
523
|
+
description:
|
|
524
|
+
'Resend the verification email. Public endpoint — no authentication required. Always returns success to prevent email enumeration. A verification email is also sent automatically on registration. Rate limited to 3 requests per 15 minutes.',
|
|
525
|
+
},
|
|
526
|
+
response: [],
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Add Verify Account request (public)
|
|
530
|
+
authFolder.item.push({
|
|
531
|
+
name: 'Verify Account',
|
|
532
|
+
request: {
|
|
533
|
+
auth: {
|
|
534
|
+
type: 'noauth',
|
|
535
|
+
},
|
|
536
|
+
method: 'POST',
|
|
537
|
+
header: [
|
|
538
|
+
{
|
|
539
|
+
key: 'Content-Type',
|
|
540
|
+
value: 'application/json',
|
|
541
|
+
},
|
|
542
|
+
],
|
|
543
|
+
body: {
|
|
544
|
+
mode: 'raw',
|
|
545
|
+
raw: '{\n "token": "paste-verification-token-from-email-here"\n}',
|
|
546
|
+
},
|
|
547
|
+
url: {
|
|
548
|
+
raw: '{{baseUrl}}/auth/verify-account',
|
|
549
|
+
host: ['{{baseUrl}}'],
|
|
550
|
+
path: ['auth', 'verify-account'],
|
|
551
|
+
},
|
|
552
|
+
description:
|
|
553
|
+
'Verify a user account using the token from the verification email. Public endpoint — no authentication required. On success, sets auth cookies (access_token + refresh_token) so the user is immediately logged in.',
|
|
554
|
+
},
|
|
555
|
+
event: [
|
|
556
|
+
{
|
|
557
|
+
listen: 'test',
|
|
558
|
+
script: {
|
|
559
|
+
type: 'text/javascript',
|
|
560
|
+
exec: [
|
|
561
|
+
'if (pm.response.code === 200) {',
|
|
562
|
+
' const res = pm.response.json();',
|
|
563
|
+
' if (res.data && res.data.user) {',
|
|
564
|
+
" pm.collectionVariables.set('userId', res.data.user.id);",
|
|
565
|
+
' }',
|
|
566
|
+
' // Tokens are set via httpOnly cookies, not in response body',
|
|
567
|
+
'}',
|
|
568
|
+
],
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
],
|
|
572
|
+
response: [],
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
await fs.writeJson(filePath, collection, { spaces: 2 });
|
|
576
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMutation } from '@tanstack/react-query';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { toast } from 'sonner';
|
|
6
|
+
|
|
7
|
+
import { useAppDispatch } from '@/store/hooks';
|
|
8
|
+
import { getErrorMessage, isErrorCode, ERROR_CODES } from '@/lib/utils/error';
|
|
9
|
+
import { ROUTES } from '@/lib/constants/routes';
|
|
10
|
+
import { verificationService } from '../services/verification.service';
|
|
11
|
+
import { setUser } from '../store/authSlice';
|
|
12
|
+
|
|
13
|
+
interface UseSendVerificationReturn {
|
|
14
|
+
sendVerification: (email: string) => void;
|
|
15
|
+
isPending: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const useSendVerification = (): UseSendVerificationReturn => {
|
|
19
|
+
const router = useRouter();
|
|
20
|
+
|
|
21
|
+
const mutation = useMutation({
|
|
22
|
+
mutationFn: (email: string) => verificationService.sendVerification(email),
|
|
23
|
+
onSuccess: () => {
|
|
24
|
+
toast.success('Verification email sent! Please check your inbox.');
|
|
25
|
+
},
|
|
26
|
+
onError: (error: unknown) => {
|
|
27
|
+
if (isErrorCode(error, ERROR_CODES.ALREADY_VERIFIED)) {
|
|
28
|
+
toast.info('Your account is already verified');
|
|
29
|
+
router.push(ROUTES.DASHBOARD);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
toast.error(getErrorMessage(error));
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
sendVerification: mutation.mutate,
|
|
38
|
+
isPending: mutation.isPending,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
interface UseVerifyAccountReturn {
|
|
43
|
+
verifyAccount: (token: string) => void;
|
|
44
|
+
isPending: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const useVerifyAccount = (): UseVerifyAccountReturn => {
|
|
48
|
+
const dispatch = useAppDispatch();
|
|
49
|
+
const router = useRouter();
|
|
50
|
+
|
|
51
|
+
const mutation = useMutation({
|
|
52
|
+
mutationFn: (token: string) => verificationService.verifyAccount(token),
|
|
53
|
+
onSuccess: (data) => {
|
|
54
|
+
dispatch(setUser(data.user));
|
|
55
|
+
toast.success('Account verified successfully!');
|
|
56
|
+
router.push(ROUTES.DASHBOARD);
|
|
57
|
+
},
|
|
58
|
+
onError: (error: unknown) => {
|
|
59
|
+
toast.error(getErrorMessage(error));
|
|
60
|
+
if (isErrorCode(error, ERROR_CODES.INVALID_VERIFICATION_TOKEN)) {
|
|
61
|
+
router.push(ROUTES.LOGIN);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
verifyAccount: mutation.mutate,
|
|
68
|
+
isPending: mutation.isPending,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { apiClient } from '@/lib/api/axios.config';
|
|
2
|
+
import { API_ENDPOINTS } from '@/lib/constants/api-endpoints';
|
|
3
|
+
|
|
4
|
+
import type { ApiResponse } from '@/lib/api/api.types';
|
|
5
|
+
import type { IUser } from '../types/auth.types';
|
|
6
|
+
|
|
7
|
+
interface AuthResponse {
|
|
8
|
+
user: IUser;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class VerificationService {
|
|
12
|
+
async sendVerification(email: string): Promise<void> {
|
|
13
|
+
await apiClient.post(API_ENDPOINTS.AUTH.SEND_VERIFICATION, { email });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async verifyAccount(token: string): Promise<AuthResponse> {
|
|
17
|
+
const response = await apiClient.post<ApiResponse<AuthResponse>>(
|
|
18
|
+
API_ENDPOINTS.AUTH.VERIFY_ACCOUNT,
|
|
19
|
+
{ token },
|
|
20
|
+
);
|
|
21
|
+
return response.data.data;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const verificationService = new VerificationService();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import { successResponse } from '@shared/responses/successResponse.js';
|
|
3
|
+
import { setAuthCookies } from '@libs/cookies.js';
|
|
4
|
+
import * as verificationService from './verification.service.js';
|
|
5
|
+
|
|
6
|
+
import type { SendVerificationInput, VerifyAccountInput } from './auth.schemas.js';
|
|
7
|
+
|
|
8
|
+
export async function sendVerification(
|
|
9
|
+
request: FastifyRequest<{ Body: SendVerificationInput }>,
|
|
10
|
+
reply: FastifyReply,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
await verificationService.sendVerification(request.body.email);
|
|
13
|
+
// Always return success to prevent email enumeration (same pattern as forgotPassword)
|
|
14
|
+
reply.send(successResponse('If an account exists with that email, a verification link has been sent.', null));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function verifyAccount(
|
|
18
|
+
request: FastifyRequest<{ Body: VerifyAccountInput }>,
|
|
19
|
+
reply: FastifyReply,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const deviceInfo = request.headers['user-agent'];
|
|
22
|
+
const ipAddress = request.ip;
|
|
23
|
+
|
|
24
|
+
const result = await verificationService.verifyAccount(request.body.token, deviceInfo, ipAddress);
|
|
25
|
+
|
|
26
|
+
setAuthCookies(reply, result.accessToken, result.refreshToken);
|
|
27
|
+
reply.send(successResponse('Account verified successfully', { user: result.user }));
|
|
28
|
+
}
|