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.
Files changed (27) hide show
  1. package/bin/create-tigra.js +153 -1
  2. package/lib/patchers/email-verification.patcher.js +576 -0
  3. package/modules/email-verification/client/hooks/useVerification.ts +70 -0
  4. package/modules/email-verification/client/services/verification.service.ts +25 -0
  5. package/modules/email-verification/server/verification.controller.ts +28 -0
  6. package/modules/email-verification/server/verification.service.ts +190 -0
  7. package/package.json +5 -2
  8. package/template/client/src/features/auth/components/AuthInitializer.tsx +7 -1
  9. package/template/client/src/features/auth/hooks/useAuth.ts +10 -1
  10. package/template/client/src/features/auth/hooks/usePasswordReset.ts +57 -0
  11. package/template/client/src/features/auth/services/auth.service.ts +2 -2
  12. package/template/client/src/lib/constants/api-endpoints.ts +1 -1
  13. package/template/client/src/lib/constants/routes.ts +1 -1
  14. package/template/client/src/lib/utils/error.ts +4 -0
  15. package/template/server/.env.example +29 -0
  16. package/template/server/.env.example.production +22 -0
  17. package/template/server/package-lock.json +6823 -0
  18. package/template/server/package.json +1 -0
  19. package/template/server/src/config/env.ts +18 -1
  20. package/template/server/src/config/rate-limit.config.ts +8 -0
  21. package/template/server/src/libs/auth.ts +4 -1
  22. package/template/server/src/libs/email.ts +40 -0
  23. package/template/server/src/modules/auth/auth.controller.ts +27 -1
  24. package/template/server/src/modules/auth/auth.repo.ts +1 -0
  25. package/template/server/src/modules/auth/auth.routes.ts +24 -0
  26. package/template/server/src/modules/auth/auth.schemas.ts +18 -0
  27. package/template/server/src/modules/auth/auth.service.ts +136 -4
@@ -0,0 +1,190 @@
1
+ import crypto from 'node:crypto';
2
+ import { signAccessToken, generateRefreshToken, getRefreshTokenExpiresAt } from '@libs/auth.js';
3
+ import { getRedis } from '@libs/redis.js';
4
+ import { sendEmail } from '@libs/email.js';
5
+ import { logger } from '@libs/logger.js';
6
+ import { env } from '@config/env.js';
7
+ import {
8
+ BadRequestError,
9
+ NotFoundError,
10
+ } from '@shared/errors/errors.js';
11
+ import * as authRepo from './auth.repo.js';
12
+ import { sessionRepository } from './session.repo.js';
13
+ import { sanitizeUser } from './auth.service.js';
14
+
15
+ import type { AuthResult } from './auth.service.js';
16
+
17
+ const VERIFICATION_TTL = 3600; // 1 hour in seconds
18
+ const VERIFICATION_PREFIX = 'verify:';
19
+ const VERIFICATION_USER_PREFIX = 'verify-user:';
20
+
21
+ /**
22
+ * Send a verification email to a user.
23
+ * Public endpoint — accepts email, silent return if user not found (prevents enumeration).
24
+ * Also called internally by the register flow for auto-sending.
25
+ */
26
+ export async function sendVerification(email: string): Promise<void> {
27
+ const user = await authRepo.findUserByEmail(email);
28
+
29
+ // Silent return if user not found — prevents email enumeration (same pattern as forgotPassword)
30
+ if (!user) return;
31
+
32
+ // Silent return if already verified — no need to reveal account status
33
+ if (user.isActive) return;
34
+
35
+ const redis = getRedis();
36
+
37
+ // Invalidate any existing verification token for this user
38
+ const existingToken = await redis.get(`${VERIFICATION_USER_PREFIX}${user.id}`);
39
+ if (existingToken) {
40
+ await redis.del(`${VERIFICATION_PREFIX}${existingToken}`);
41
+ await redis.del(`${VERIFICATION_USER_PREFIX}${user.id}`);
42
+ }
43
+
44
+ // Generate secure token and store in Redis with 1 hour TTL
45
+ const token = crypto.randomBytes(32).toString('hex');
46
+
47
+ await redis.set(`${VERIFICATION_PREFIX}${token}`, user.id, 'EX', VERIFICATION_TTL);
48
+ await redis.set(`${VERIFICATION_USER_PREFIX}${user.id}`, token, 'EX', VERIFICATION_TTL);
49
+
50
+ // Build verification URL and send email
51
+ const verifyUrl = `${env.CLIENT_URL}/verify-account?token=${token}`;
52
+ const safeName = user.firstName.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
53
+
54
+ try {
55
+ await sendEmail({
56
+ to: user.email,
57
+ subject: 'Verify your account',
58
+ html: `
59
+ <table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f3ee; padding: 40px 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
60
+ <tr>
61
+ <td align="center">
62
+ <table width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.06); border-left: 4px solid #c15f3c;">
63
+ <tr>
64
+ <td style="padding: 40px;">
65
+ <p style="margin: 0 0 20px; font-size: 12px; font-weight: 600; color: #c15f3c; text-transform: uppercase; letter-spacing: 1.5px;">Account Verification</p>
66
+ <h1 style="margin: 0 0 8px; font-size: 22px; font-weight: 600; color: #1a170f;">Verify your account</h1>
67
+ <p style="margin: 0 0 28px; font-size: 15px; color: #6b6560; line-height: 1.6;">
68
+ Hi ${safeName}, please verify your email address to activate your account and get started.
69
+ </p>
70
+ <a href="${verifyUrl}" style="display: inline-block; background-color: #1a170f; color: #ffffff; padding: 14px 32px; border-radius: 10px; text-decoration: none; font-size: 15px; font-weight: 600;">
71
+ Verify account &rarr;
72
+ </a>
73
+ <p style="margin: 28px 0 0; font-size: 13px; color: #9b958e; line-height: 1.6;">
74
+ This link expires in 1 hour. If you didn't create an account, you can safely ignore this email.
75
+ </p>
76
+ </td>
77
+ </tr>
78
+ <tr>
79
+ <td style="padding: 0 40px 32px;">
80
+ <div style="border-top: 1px solid #e8e6e1; padding-top: 20px;">
81
+ <p style="margin: 0; font-size: 12px; color: #b5b0a8; line-height: 1.5;">
82
+ If the button doesn't work, copy this link:<br/>
83
+ <a href="${verifyUrl}" style="color: #c15f3c; text-decoration: underline; word-break: break-all;">${verifyUrl}</a>
84
+ </p>
85
+ </div>
86
+ </td>
87
+ </tr>
88
+ </table>
89
+ </td>
90
+ </tr>
91
+ </table>
92
+ `,
93
+ });
94
+
95
+ logger.info({ userId: user.id }, '[AUTH] Verification email sent');
96
+ } catch (error) {
97
+ // Log but don't throw — prevents leaking email existence via 500 vs 200
98
+ logger.error({ userId: user.id, err: error }, '[AUTH] Failed to send verification email');
99
+ }
100
+ }
101
+
102
+ export async function verifyAccount(
103
+ token: string,
104
+ deviceInfo?: string,
105
+ ipAddress?: string,
106
+ ): Promise<AuthResult> {
107
+ const redis = getRedis();
108
+
109
+ // Atomic get-and-delete to prevent token reuse via concurrent requests
110
+ const userId = await redis.getDel(`${VERIFICATION_PREFIX}${token}`);
111
+ if (!userId) {
112
+ throw new BadRequestError('Invalid or expired verification token', 'INVALID_VERIFICATION_TOKEN');
113
+ }
114
+
115
+ const user = await authRepo.findUserById(userId);
116
+ if (!user) {
117
+ throw new NotFoundError('User not found', 'USER_NOT_FOUND');
118
+ }
119
+
120
+ // Activate the user account
121
+ await authRepo.activateUser(userId);
122
+
123
+ // Clean up the reverse-lookup key
124
+ await redis.del(`${VERIFICATION_USER_PREFIX}${userId}`);
125
+
126
+ // Generate tokens and create session (same pattern as login)
127
+ const accessToken = signAccessToken({
128
+ userId: user.id,
129
+ role: user.role,
130
+ });
131
+ const refreshToken = generateRefreshToken();
132
+ const refreshTokenExpiresAt = getRefreshTokenExpiresAt();
133
+
134
+ const session = await sessionRepository.createSession({
135
+ userId: user.id,
136
+ deviceInfo,
137
+ ipAddress,
138
+ expiresAt: refreshTokenExpiresAt,
139
+ });
140
+
141
+ await authRepo.createRefreshToken({
142
+ token: refreshToken,
143
+ userId: user.id,
144
+ sessionId: session.id,
145
+ expiresAt: refreshTokenExpiresAt,
146
+ });
147
+
148
+ logger.info({ userId }, '[AUTH] Account verified');
149
+
150
+ // Send welcome email (best-effort — don't fail verification if email fails)
151
+ const safeName = user.firstName.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
152
+ const dashboardUrl = `${env.CLIENT_URL}/dashboard`;
153
+
154
+ try {
155
+ await sendEmail({
156
+ to: user.email,
157
+ subject: 'Welcome — your account is verified!',
158
+ html: `
159
+ <table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f3ee; padding: 40px 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
160
+ <tr>
161
+ <td align="center">
162
+ <table width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.06); border-left: 4px solid #c15f3c;">
163
+ <tr>
164
+ <td style="padding: 40px;">
165
+ <p style="margin: 0 0 20px; font-size: 12px; font-weight: 600; color: #c15f3c; text-transform: uppercase; letter-spacing: 1.5px;">Welcome</p>
166
+ <h1 style="margin: 0 0 8px; font-size: 22px; font-weight: 600; color: #1a170f;">You're all set!</h1>
167
+ <p style="margin: 0 0 28px; font-size: 15px; color: #6b6560; line-height: 1.6;">
168
+ Hi ${safeName}, your account has been verified. You can now access all features of the app.
169
+ </p>
170
+ <a href="${dashboardUrl}" style="display: inline-block; background-color: #1a170f; color: #ffffff; padding: 14px 32px; border-radius: 10px; text-decoration: none; font-size: 15px; font-weight: 600;">
171
+ Go to dashboard &rarr;
172
+ </a>
173
+ </td>
174
+ </tr>
175
+ </table>
176
+ </td>
177
+ </tr>
178
+ </table>
179
+ `,
180
+ });
181
+ } catch (error) {
182
+ logger.error({ userId, err: error }, '[AUTH] Failed to send welcome email');
183
+ }
184
+
185
+ return {
186
+ user: sanitizeUser({ ...user, isActive: true }),
187
+ accessToken,
188
+ refreshToken,
189
+ };
190
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-tigra",
3
- "version": "2.6.8",
3
+ "version": "2.7.1",
4
4
  "type": "module",
5
5
  "description": "Create a production-ready full-stack app with Next.js 16 + Fastify 5 + Prisma + Redis",
6
6
  "bin": {
@@ -8,6 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
+ "lib",
12
+ "modules",
11
13
  "template"
12
14
  ],
13
15
  "engines": {
@@ -47,6 +49,7 @@
47
49
  "commander": "^13.1.0",
48
50
  "fs-extra": "^11.3.0",
49
51
  "ora": "^8.2.0",
50
- "prompts": "^2.4.2"
52
+ "prompts": "^2.4.2",
53
+ "ts-morph": "^27.0.2"
51
54
  }
52
55
  }
@@ -5,8 +5,11 @@ import { useEffect } from 'react';
5
5
 
6
6
  import { usePathname, useRouter } from 'next/navigation';
7
7
 
8
+ import { toast } from 'sonner';
9
+
8
10
  import { useAppDispatch, useAppSelector } from '@/store/hooks';
9
11
  import { ROUTES } from '@/lib/constants/routes';
12
+ import { isErrorCode, ERROR_CODES } from '@/lib/utils/error';
10
13
  import { authService } from '../services/auth.service';
11
14
  import { setUser, setInitialized } from '../store/authSlice';
12
15
 
@@ -15,7 +18,7 @@ const PROTECTED_PATHS: string[] = [ROUTES.DASHBOARD, ROUTES.PROFILE, '/admin'];
15
18
  // Auth pages where getMe() should never fire — there is no session to hydrate
16
19
  // on login/register/reset-password, and calling getMe() here would trigger the
17
20
  // 401 → refresh → fail → redirect chain for no reason.
18
- const AUTH_PATHS: string[] = [ROUTES.LOGIN, ROUTES.REGISTER, ROUTES.RESET_PASSWORD, ROUTES.VERIFY_EMAIL];
21
+ const AUTH_PATHS: string[] = [ROUTES.LOGIN, ROUTES.REGISTER, ROUTES.RESET_PASSWORD, ROUTES.VERIFY_ACCOUNT];
19
22
 
20
23
  interface AuthInitializerProps {
21
24
  children: React.ReactNode;
@@ -65,6 +68,9 @@ export const AuthInitializer = ({ children }: AuthInitializerProps): React.React
65
68
  // Only redirect on auth errors (401/403), not network failures
66
69
  const status = error?.response?.status;
67
70
  if (status === 401 || status === 403) {
71
+ if (isErrorCode(error, ERROR_CODES.ACCOUNT_NOT_ACTIVE)) {
72
+ toast.error('Your account is not yet activated. Please verify your account.');
73
+ }
68
74
  router.push(ROUTES.LOGIN);
69
75
  }
70
76
  });
@@ -7,7 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
7
7
  import { toast } from 'sonner';
8
8
 
9
9
  import { useAppDispatch, useAppSelector } from '@/store/hooks';
10
- import { getErrorMessage } from '@/lib/utils/error';
10
+ import { getErrorMessage, isErrorCode, ERROR_CODES } from '@/lib/utils/error';
11
11
  import { ROUTES } from '@/lib/constants/routes';
12
12
  import { authService } from '../services/auth.service';
13
13
  import { setUser, setLoggingOut, logout as logoutAction } from '../store/authSlice';
@@ -42,6 +42,10 @@ export const useAuth = (): UseAuthReturn => {
42
42
  router.push(redirectTo);
43
43
  },
44
44
  onError: (error) => {
45
+ if (isErrorCode(error, ERROR_CODES.ACCOUNT_NOT_ACTIVE)) {
46
+ toast.error('Your account is not yet activated. Please verify your account to continue.');
47
+ return;
48
+ }
45
49
  toast.error(getErrorMessage(error));
46
50
  },
47
51
  });
@@ -49,6 +53,11 @@ export const useAuth = (): UseAuthReturn => {
49
53
  const registerMutation = useMutation({
50
54
  mutationFn: (data: IRegisterRequest) => authService.register(data),
51
55
  onSuccess: (data) => {
56
+ if (!data.user.isActive) {
57
+ toast.success('Account created! Please verify your account to continue.');
58
+ router.push(ROUTES.VERIFY_ACCOUNT);
59
+ return;
60
+ }
52
61
  dispatch(setUser(data.user));
53
62
  toast.success('Account created successfully');
54
63
  router.push(ROUTES.DASHBOARD);
@@ -0,0 +1,57 @@
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 { ROUTES } from '@/lib/constants/routes';
8
+ import { getErrorMessage } from '@/lib/utils/error';
9
+ import { authService } from '../services/auth.service';
10
+
11
+ interface UseForgotPasswordReturn {
12
+ forgotPassword: (email: string) => void;
13
+ isPending: boolean;
14
+ }
15
+
16
+ export const useForgotPassword = (): UseForgotPasswordReturn => {
17
+ const mutation = useMutation({
18
+ mutationFn: (email: string) => authService.forgotPassword(email),
19
+ onSuccess: () => {
20
+ toast.success('Check your email for reset instructions');
21
+ },
22
+ onError: (error: unknown) => {
23
+ toast.error(getErrorMessage(error));
24
+ },
25
+ });
26
+
27
+ return {
28
+ forgotPassword: mutation.mutate,
29
+ isPending: mutation.isPending,
30
+ };
31
+ };
32
+
33
+ interface UseResetPasswordReturn {
34
+ resetPassword: (data: { token: string; newPassword: string }) => void;
35
+ isPending: boolean;
36
+ }
37
+
38
+ export const useResetPassword = (): UseResetPasswordReturn => {
39
+ const router = useRouter();
40
+
41
+ const mutation = useMutation({
42
+ mutationFn: (data: { token: string; newPassword: string }) =>
43
+ authService.resetPassword(data.token, data.newPassword),
44
+ onSuccess: () => {
45
+ toast.success('Password reset successfully. Please sign in.');
46
+ router.push(ROUTES.LOGIN);
47
+ },
48
+ onError: (error: unknown) => {
49
+ toast.error(getErrorMessage(error));
50
+ },
51
+ });
52
+
53
+ return {
54
+ resetPassword: mutation.mutate,
55
+ isPending: mutation.isPending,
56
+ };
57
+ };
@@ -36,8 +36,8 @@ class AuthService {
36
36
  return response.data.data;
37
37
  }
38
38
 
39
- async requestPasswordReset(email: string): Promise<void> {
40
- await apiClient.post(API_ENDPOINTS.AUTH.REQUEST_PASSWORD_RESET, { email });
39
+ async forgotPassword(email: string): Promise<void> {
40
+ await apiClient.post(API_ENDPOINTS.AUTH.FORGOT_PASSWORD, { email });
41
41
  }
42
42
 
43
43
  async resetPassword(token: string, newPassword: string): Promise<void> {
@@ -5,7 +5,7 @@ export const API_ENDPOINTS = {
5
5
  LOGOUT: '/auth/logout',
6
6
  REFRESH: '/auth/refresh',
7
7
  ME: '/auth/me',
8
- REQUEST_PASSWORD_RESET: '/auth/request-password-reset',
8
+ FORGOT_PASSWORD: '/auth/forgot-password',
9
9
  RESET_PASSWORD: '/auth/reset-password',
10
10
  },
11
11
  USERS: {
@@ -2,7 +2,7 @@ export const ROUTES = {
2
2
  HOME: '/',
3
3
  LOGIN: '/login',
4
4
  REGISTER: '/register',
5
- VERIFY_EMAIL: '/verify-email',
5
+ VERIFY_ACCOUNT: '/verify-account',
6
6
  RESET_PASSWORD: '/reset-password',
7
7
  DASHBOARD: '/dashboard',
8
8
  PROFILE: '/profile',
@@ -30,3 +30,7 @@ export const getErrorCode = (error: unknown): string | undefined => {
30
30
  export const isErrorCode = (error: unknown, code: string): boolean => {
31
31
  return getErrorCode(error) === code;
32
32
  };
33
+
34
+ export const ERROR_CODES = {
35
+ ACCOUNT_NOT_ACTIVE: 'ACCOUNT_NOT_ACTIVE',
36
+ } as const;
@@ -67,6 +67,17 @@ RATE_LIMIT_MULTIPLIER=1
67
67
  # RATE_LIMIT_AUTH_LOGIN_MAX=10
68
68
  # RATE_LIMIT_AUTH_REGISTER_MAX=5
69
69
 
70
+ # ===================================================================
71
+ # ACCOUNT ACTIVATION
72
+ # ===================================================================
73
+
74
+ # When true (default), new users are created as inactive and must
75
+ # verify their account before they can log in.
76
+ # When false, users are active immediately after registration.
77
+ # NOTE: When this variable is not provided, users are NOT activated
78
+ # by default — you must explicitly set it to false to skip verification.
79
+ REQUIRE_USER_VERIFICATION=true
80
+
70
81
  # ===================================================================
71
82
  # FILE UPLOAD
72
83
  # ===================================================================
@@ -138,6 +149,24 @@ JWT_REFRESH_EXPIRY="7d"
138
149
  # Local dev: CORS_ORIGIN="http://localhost:3001"
139
150
  # CORS_ORIGIN="http://localhost:3001"
140
151
 
152
+ # ===================================================================
153
+ # EMAIL (Resend)
154
+ # ===================================================================
155
+
156
+ # Resend API key for transactional emails (password reset, verification, etc.)
157
+ # Get your API key from: https://resend.com/api-keys
158
+ # COOLIFY: Runtime only. Do NOT check "Available at Buildtime" — this is a secret!
159
+ RESEND_API_KEY="re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
160
+
161
+ # Sender email address
162
+ # Development: Use Resend's test address (onboarding@resend.dev) — delivers to your Resend dashboard
163
+ # Production: Use a verified domain email (e.g., noreply@yourdomain.com)
164
+ RESEND_FROM_EMAIL="onboarding@resend.dev"
165
+
166
+ # Frontend URL used to build links in emails (e.g., password reset links)
167
+ # Must match the URL where your Next.js client is running
168
+ CLIENT_URL="http://localhost:3000"
169
+
141
170
  # ===================================================================
142
171
  # LOGGING
143
172
  # ===================================================================
@@ -65,6 +65,14 @@ RATE_LIMIT_MULTIPLIER=1
65
65
  RATE_LIMIT_AUTH_LOGIN_MAX=10
66
66
  RATE_LIMIT_AUTH_REGISTER_MAX=5
67
67
 
68
+ # ===================================================================
69
+ # ACCOUNT ACTIVATION
70
+ # ===================================================================
71
+
72
+ # Require account verification before users can log in
73
+ # Set to true in production for security
74
+ REQUIRE_USER_VERIFICATION=true
75
+
68
76
  # ===================================================================
69
77
  # FILE UPLOAD
70
78
  # ===================================================================
@@ -112,6 +120,20 @@ CORS_ORIGIN="https://yourdomain.com,https://app.yourdomain.com"
112
120
  # Without it, login will silently fail (server returns 200 but browser drops cookies).
113
121
  COOKIE_DOMAIN=".yourdomain.com"
114
122
 
123
+ # ===================================================================
124
+ # EMAIL (Resend)
125
+ # ===================================================================
126
+
127
+ # Resend API key for transactional emails
128
+ # COOLIFY: Runtime only. Do NOT check "Available at Buildtime" — this is a secret!
129
+ RESEND_API_KEY="re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
130
+
131
+ # Sender email — must be from a verified domain in Resend
132
+ RESEND_FROM_EMAIL="noreply@yourdomain.com"
133
+
134
+ # Frontend URL for email links (password reset, verification, etc.)
135
+ CLIENT_URL="https://yourdomain.com"
136
+
115
137
  # ===================================================================
116
138
  # LOGGING
117
139
  # ===================================================================