create-tigra 2.6.5 → 2.7.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 (59) hide show
  1. package/bin/create-tigra.js +144 -0
  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/_claude/rules/client/01-project-structure.md +2 -5
  9. package/template/_claude/rules/client/04-design-system.md +48 -43
  10. package/template/_claude/rules/client/core.md +2 -2
  11. package/template/client/src/app/globals.css +12 -12
  12. package/template/client/src/app/layout.tsx +1 -1
  13. package/template/client/src/app/page.tsx +5 -5
  14. package/template/client/src/app/providers.tsx +1 -1
  15. package/template/client/src/components/common/ThemeToggle.tsx +59 -0
  16. package/template/client/src/features/admin/hooks/useAdminSessions.ts +68 -0
  17. package/template/client/src/features/admin/hooks/useAdminStats.ts +27 -0
  18. package/template/client/src/features/admin/hooks/useAdminUsers.ts +132 -0
  19. package/template/client/src/features/admin/services/admin.service.ts +94 -0
  20. package/template/client/src/features/admin/types/admin.types.ts +65 -0
  21. package/template/client/src/features/auth/components/AuthInitializer.tsx +24 -1
  22. package/template/client/src/features/auth/hooks/useAuth.ts +10 -1
  23. package/template/client/src/features/auth/hooks/usePasswordReset.ts +57 -0
  24. package/template/client/src/features/auth/services/auth.service.ts +2 -2
  25. package/template/client/src/lib/api/axios.config.ts +20 -1
  26. package/template/client/src/lib/constants/api-endpoints.ts +10 -1
  27. package/template/client/src/lib/constants/app.constants.ts +3 -1
  28. package/template/client/src/lib/constants/routes.ts +7 -1
  29. package/template/client/src/lib/env.ts +35 -0
  30. package/template/client/src/lib/utils/error.ts +4 -0
  31. package/template/client/src/styles/themes/default.css +92 -0
  32. package/template/server/.env.example +29 -0
  33. package/template/server/.env.example.production +22 -0
  34. package/template/server/package-lock.json +6823 -0
  35. package/template/server/package.json +2 -0
  36. package/template/server/postman/collection.json +168 -50
  37. package/template/server/prisma/schema.prisma +2 -0
  38. package/template/server/src/config/env.ts +18 -1
  39. package/template/server/src/config/rate-limit.config.ts +8 -0
  40. package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +14 -4
  41. package/template/server/src/libs/auth.ts +4 -1
  42. package/template/server/src/libs/email.ts +40 -0
  43. package/template/server/src/libs/prisma.ts +13 -0
  44. package/template/server/src/modules/admin/admin.controller.ts +130 -1
  45. package/template/server/src/modules/admin/admin.repo.ts +289 -0
  46. package/template/server/src/modules/admin/admin.routes.ts +113 -7
  47. package/template/server/src/modules/admin/admin.schemas.ts +49 -0
  48. package/template/server/src/modules/admin/admin.service.ts +154 -0
  49. package/template/server/src/modules/auth/auth.controller.ts +27 -1
  50. package/template/server/src/modules/auth/auth.repo.ts +6 -18
  51. package/template/server/src/modules/auth/auth.routes.ts +24 -0
  52. package/template/server/src/modules/auth/auth.schemas.ts +18 -0
  53. package/template/server/src/modules/auth/auth.service.ts +156 -32
  54. package/template/server/src/modules/auth/session.repo.ts +10 -5
  55. package/template/client/src/components/common/ThemeSwitcher.tsx +0 -112
  56. package/template/client/src/styles/themes/electric-indigo.css +0 -90
  57. package/template/client/src/styles/themes/ocean-teal.css +0 -90
  58. package/template/client/src/styles/themes/rose-pink.css +0 -90
  59. package/template/client/src/styles/themes/warm-orange.css +0 -90
@@ -0,0 +1,132 @@
1
+ 'use client';
2
+
3
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
4
+ import { toast } from 'sonner';
5
+
6
+ import { getErrorMessage } from '@/lib/utils/error';
7
+ import { adminService } from '../services/admin.service';
8
+
9
+ import type { IAdminUser, IAdminUserDetail, IGetUsersParams, IGetSessionsParams } from '../types/admin.types';
10
+
11
+ // ─── Query Key Factory ──────────────────────────────────────────────────────
12
+
13
+ export const adminKeys = {
14
+ all: ['admin'] as const,
15
+ stats: () => [...adminKeys.all, 'stats'] as const,
16
+ users: () => [...adminKeys.all, 'users'] as const,
17
+ userList: (params: IGetUsersParams) => [...adminKeys.users(), 'list', params] as const,
18
+ userDetail: (userId: string) => [...adminKeys.users(), 'detail', userId] as const,
19
+ sessions: () => [...adminKeys.all, 'sessions'] as const,
20
+ sessionList: (params: IGetSessionsParams) => [...adminKeys.sessions(), 'list', params] as const,
21
+ };
22
+
23
+ // ─── User List ──────────────────────────────────────────────────────────────
24
+
25
+ interface UseAdminUsersReturn {
26
+ users: IAdminUser[];
27
+ pagination: {
28
+ page: number;
29
+ limit: number;
30
+ totalItems: number;
31
+ totalPages: number;
32
+ hasNextPage: boolean;
33
+ hasPreviousPage: boolean;
34
+ } | undefined;
35
+ isLoading: boolean;
36
+ error: Error | null;
37
+ }
38
+
39
+ export const useAdminUsers = (params: IGetUsersParams = {}): UseAdminUsersReturn => {
40
+ const { data, isLoading, error } = useQuery({
41
+ queryKey: adminKeys.userList(params),
42
+ queryFn: () => adminService.getUsers(params),
43
+ });
44
+
45
+ return {
46
+ users: data?.items ?? [],
47
+ pagination: data?.pagination,
48
+ isLoading,
49
+ error,
50
+ };
51
+ };
52
+
53
+ // ─── User Detail ────────────────────────────────────────────────────────────
54
+
55
+ interface UseAdminUserDetailReturn {
56
+ user: IAdminUserDetail | undefined;
57
+ isLoading: boolean;
58
+ error: Error | null;
59
+ }
60
+
61
+ export const useAdminUserDetail = (userId: string): UseAdminUserDetailReturn => {
62
+ const { data, isLoading, error } = useQuery({
63
+ queryKey: adminKeys.userDetail(userId),
64
+ queryFn: () => adminService.getUserDetail(userId),
65
+ enabled: !!userId,
66
+ });
67
+
68
+ return {
69
+ user: data,
70
+ isLoading,
71
+ error,
72
+ };
73
+ };
74
+
75
+ // ─── Update User Status ─────────────────────────────────────────────────────
76
+
77
+ interface UseUpdateUserStatusReturn {
78
+ updateStatus: (params: { userId: string; isActive: boolean }) => void;
79
+ isUpdating: boolean;
80
+ }
81
+
82
+ export const useUpdateUserStatus = (): UseUpdateUserStatusReturn => {
83
+ const queryClient = useQueryClient();
84
+
85
+ const mutation = useMutation({
86
+ mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
87
+ adminService.updateUserStatus(userId, { isActive }),
88
+ onSuccess: (_data, variables) => {
89
+ const action = variables.isActive ? 'activated' : 'deactivated';
90
+ toast.success(`User ${action} successfully`);
91
+ queryClient.invalidateQueries({ queryKey: adminKeys.users() });
92
+ queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
93
+ },
94
+ onError: (error) => {
95
+ toast.error(getErrorMessage(error));
96
+ },
97
+ });
98
+
99
+ return {
100
+ updateStatus: mutation.mutate,
101
+ isUpdating: mutation.isPending,
102
+ };
103
+ };
104
+
105
+ // ─── Update User Role ───────────────────────────────────────────────────────
106
+
107
+ interface UseUpdateUserRoleReturn {
108
+ updateRole: (params: { userId: string; role: 'USER' | 'ADMIN' }) => void;
109
+ isUpdating: boolean;
110
+ }
111
+
112
+ export const useUpdateUserRole = (): UseUpdateUserRoleReturn => {
113
+ const queryClient = useQueryClient();
114
+
115
+ const mutation = useMutation({
116
+ mutationFn: ({ userId, role }: { userId: string; role: 'USER' | 'ADMIN' }) =>
117
+ adminService.updateUserRole(userId, { role }),
118
+ onSuccess: () => {
119
+ toast.success('User role updated successfully');
120
+ queryClient.invalidateQueries({ queryKey: adminKeys.users() });
121
+ queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
122
+ },
123
+ onError: (error) => {
124
+ toast.error(getErrorMessage(error));
125
+ },
126
+ });
127
+
128
+ return {
129
+ updateRole: mutation.mutate,
130
+ isUpdating: mutation.isPending,
131
+ };
132
+ };
@@ -0,0 +1,94 @@
1
+ import { apiClient } from '@/lib/api/axios.config';
2
+ import { API_ENDPOINTS } from '@/lib/constants/api-endpoints';
3
+
4
+ import type { ApiResponse, PaginatedApiResponse } from '@/lib/api/api.types';
5
+ import type {
6
+ IAdminUser,
7
+ IAdminUserDetail,
8
+ IAdminSession,
9
+ IDashboardStats,
10
+ IGetUsersParams,
11
+ IGetSessionsParams,
12
+ IUpdateUserStatusRequest,
13
+ IUpdateUserRoleRequest,
14
+ } from '../types/admin.types';
15
+
16
+ interface PaginatedData<T> {
17
+ items: T[];
18
+ pagination: {
19
+ page: number;
20
+ limit: number;
21
+ totalItems: number;
22
+ totalPages: number;
23
+ hasNextPage: boolean;
24
+ hasPreviousPage: boolean;
25
+ };
26
+ }
27
+
28
+ class AdminService {
29
+ // ─── Dashboard Stats ────────────────────────────────────────────────
30
+
31
+ async getDashboardStats(): Promise<IDashboardStats> {
32
+ const response = await apiClient.get<ApiResponse<IDashboardStats>>(
33
+ API_ENDPOINTS.ADMIN.STATS,
34
+ );
35
+ return response.data.data;
36
+ }
37
+
38
+ // ─── User Management ───────────────────────────────────────────────
39
+
40
+ async getUsers(params?: IGetUsersParams): Promise<PaginatedData<IAdminUser>> {
41
+ const response = await apiClient.get<PaginatedApiResponse<IAdminUser>>(
42
+ API_ENDPOINTS.ADMIN.USERS,
43
+ { params },
44
+ );
45
+ return response.data.data;
46
+ }
47
+
48
+ async getUserDetail(userId: string): Promise<IAdminUserDetail> {
49
+ const response = await apiClient.get<ApiResponse<IAdminUserDetail>>(
50
+ API_ENDPOINTS.ADMIN.USER_DETAIL(userId),
51
+ );
52
+ return response.data.data;
53
+ }
54
+
55
+ async updateUserStatus(
56
+ userId: string,
57
+ data: IUpdateUserStatusRequest,
58
+ ): Promise<IAdminUser> {
59
+ const response = await apiClient.patch<ApiResponse<IAdminUser>>(
60
+ API_ENDPOINTS.ADMIN.USER_STATUS(userId),
61
+ data,
62
+ );
63
+ return response.data.data;
64
+ }
65
+
66
+ async updateUserRole(
67
+ userId: string,
68
+ data: IUpdateUserRoleRequest,
69
+ ): Promise<IAdminUser> {
70
+ const response = await apiClient.patch<ApiResponse<IAdminUser>>(
71
+ API_ENDPOINTS.ADMIN.USER_ROLE(userId),
72
+ data,
73
+ );
74
+ return response.data.data;
75
+ }
76
+
77
+ // ─── Session Management ────────────────────────────────────────────
78
+
79
+ async getSessions(
80
+ params?: IGetSessionsParams,
81
+ ): Promise<PaginatedData<IAdminSession>> {
82
+ const response = await apiClient.get<PaginatedApiResponse<IAdminSession>>(
83
+ API_ENDPOINTS.ADMIN.SESSIONS,
84
+ { params },
85
+ );
86
+ return response.data.data;
87
+ }
88
+
89
+ async deleteSession(sessionId: string): Promise<void> {
90
+ await apiClient.delete(API_ENDPOINTS.ADMIN.SESSION_DELETE(sessionId));
91
+ }
92
+ }
93
+
94
+ export const adminService = new AdminService();
@@ -0,0 +1,65 @@
1
+ import type { IUser } from '@/features/auth/types/auth.types';
2
+ import type { PaginationParams } from '@/lib/api/api.types';
3
+
4
+ // ─── Admin User ─────────────────────────────────────────────────────────────
5
+
6
+ export interface IAdminUser extends IUser {
7
+ deletedAt: string | null;
8
+ failedLoginAttempts: number;
9
+ lockedUntil: string | null;
10
+ }
11
+
12
+ export interface IAdminUserDetail extends IAdminUser {
13
+ sessionCount: number;
14
+ lastLogin: string | null;
15
+ }
16
+
17
+ // ─── Admin Session ──────────────────────────────────────────────────────────
18
+
19
+ export interface IAdminSession {
20
+ id: string;
21
+ userId: string;
22
+ deviceInfo: string | null;
23
+ ipAddress: string | null;
24
+ lastActiveAt: string;
25
+ expiresAt: string;
26
+ createdAt: string;
27
+ user: {
28
+ id: string;
29
+ email: string;
30
+ firstName: string;
31
+ lastName: string;
32
+ };
33
+ }
34
+
35
+ // ─── Dashboard Stats ────────────────────────────────────────────────────────
36
+
37
+ export interface IDashboardStats {
38
+ totalUsers: number;
39
+ activeUsers: number;
40
+ adminCount: number;
41
+ recentSignups: number;
42
+ activeSessions: number;
43
+ }
44
+
45
+ // ─── Request Params ─────────────────────────────────────────────────────────
46
+
47
+ export interface IGetUsersParams extends PaginationParams {
48
+ search?: string;
49
+ role?: 'USER' | 'ADMIN';
50
+ isActive?: boolean;
51
+ sortBy?: 'createdAt' | 'email' | 'firstName';
52
+ sortOrder?: 'asc' | 'desc';
53
+ }
54
+
55
+ export interface IGetSessionsParams extends PaginationParams {
56
+ userId?: string;
57
+ }
58
+
59
+ export interface IUpdateUserStatusRequest {
60
+ isActive: boolean;
61
+ }
62
+
63
+ export interface IUpdateUserRoleRequest {
64
+ role: 'USER' | 'ADMIN';
65
+ }
@@ -5,13 +5,21 @@ 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
 
13
16
  const PROTECTED_PATHS: string[] = [ROUTES.DASHBOARD, ROUTES.PROFILE, '/admin'];
14
17
 
18
+ // Auth pages where getMe() should never fire — there is no session to hydrate
19
+ // on login/register/reset-password, and calling getMe() here would trigger the
20
+ // 401 → refresh → fail → redirect chain for no reason.
21
+ const AUTH_PATHS: string[] = [ROUTES.LOGIN, ROUTES.REGISTER, ROUTES.RESET_PASSWORD, ROUTES.VERIFY_ACCOUNT];
22
+
15
23
  interface AuthInitializerProps {
16
24
  children: React.ReactNode;
17
25
  }
@@ -20,6 +28,10 @@ function isProtectedPath(pathname: string): boolean {
20
28
  return PROTECTED_PATHS.some((path) => pathname.startsWith(path));
21
29
  }
22
30
 
31
+ function isAuthPage(pathname: string): boolean {
32
+ return AUTH_PATHS.some((path) => pathname.startsWith(path));
33
+ }
34
+
23
35
  export const AuthInitializer = ({ children }: AuthInitializerProps): React.ReactElement => {
24
36
  const dispatch = useAppDispatch();
25
37
  const pathname = usePathname();
@@ -27,7 +39,15 @@ export const AuthInitializer = ({ children }: AuthInitializerProps): React.React
27
39
  const { isAuthenticated, isLoggingOut } = useAppSelector((state) => state.auth);
28
40
 
29
41
  useEffect(() => {
30
- // On public pages, skip auth hydration just mark as initialized
42
+ // On auth pages (login, register, etc.), never call getMe().
43
+ // There is no session to hydrate, and a 401 here would trigger
44
+ // the refresh → fail → redirect chain, causing an infinite loop.
45
+ if (isAuthPage(pathname)) {
46
+ dispatch(setInitialized());
47
+ return;
48
+ }
49
+
50
+ // On other public pages, skip auth hydration — just mark as initialized
31
51
  if (!isProtectedPath(pathname)) {
32
52
  dispatch(setInitialized());
33
53
  return;
@@ -48,6 +68,9 @@ export const AuthInitializer = ({ children }: AuthInitializerProps): React.React
48
68
  // Only redirect on auth errors (401/403), not network failures
49
69
  const status = error?.response?.status;
50
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
+ }
51
74
  router.push(ROUTES.LOGIN);
52
75
  }
53
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> {
@@ -4,9 +4,10 @@ import type { InternalAxiosRequestConfig } from 'axios';
4
4
 
5
5
  import { API_ENDPOINTS } from '@/lib/constants/api-endpoints';
6
6
  import { ROUTES } from '@/lib/constants/routes';
7
+ import { env } from '@/lib/env';
7
8
 
8
9
  const apiClient = axios.create({
9
- baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000/api/v1',
10
+ baseURL: env.NEXT_PUBLIC_API_BASE_URL,
10
11
  timeout: 30000,
11
12
  withCredentials: true, // Send cookies with every request
12
13
  });
@@ -18,6 +19,14 @@ let refreshTimestamp = 0;
18
19
  const REFRESH_TIMEOUT_MS = 15000;
19
20
  let failedQueue: { resolve: () => void; reject: (error: unknown) => void }[] = [];
20
21
 
22
+ // Circuit breaker — once a refresh fails with a definitive auth error (4xx),
23
+ // this flag is set BEFORE dispatching logout or redirecting. All subsequent
24
+ // 401s are immediately rejected without entering the refresh flow, preventing
25
+ // the race condition where Redux state changes trigger re-renders that fire
26
+ // new API calls before the hard redirect completes.
27
+ // Resets automatically on page reload (window.location.href re-initializes the module).
28
+ let isSessionDead = false;
29
+
21
30
  const processQueue = (error: unknown): void => {
22
31
  failedQueue.forEach((promise) => {
23
32
  if (error) {
@@ -43,6 +52,12 @@ apiClient.interceptors.response.use(
43
52
  return Promise.reject(error);
44
53
  }
45
54
 
55
+ // Circuit breaker tripped — session is dead, redirect is pending.
56
+ // Reject immediately without attempting refresh or queuing.
57
+ if (isSessionDead) {
58
+ return Promise.reject(error);
59
+ }
60
+
46
61
  // Don't retry auth endpoints that don't use tokens —
47
62
  // a 401 here means wrong credentials, not an expired token.
48
63
  const noRetryEndpoints: string[] = [
@@ -93,6 +108,10 @@ apiClient.interceptors.response.use(
93
108
  refreshError.response.status < 500;
94
109
 
95
110
  if (isAuthFailure) {
111
+ // Trip the circuit breaker FIRST — prevents any subsequent 401s
112
+ // from re-entering this flow while the redirect is pending.
113
+ isSessionDead = true;
114
+
96
115
  const { logout } = await import('@/features/auth/store/authSlice');
97
116
  const { store } = await import('@/store');
98
117
  store.dispatch(logout());
@@ -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: {
@@ -13,4 +13,13 @@ export const API_ENDPOINTS = {
13
13
  UPDATE_ME: '/users/me',
14
14
  DELETE_ME: '/users/me',
15
15
  },
16
+ ADMIN: {
17
+ STATS: '/admin/stats',
18
+ USERS: '/admin/users',
19
+ USER_DETAIL: (userId: string) => `/admin/users/${userId}`,
20
+ USER_STATUS: (userId: string) => `/admin/users/${userId}/status`,
21
+ USER_ROLE: (userId: string) => `/admin/users/${userId}/role`,
22
+ SESSIONS: '/admin/sessions',
23
+ SESSION_DELETE: (sessionId: string) => `/admin/sessions/${sessionId}`,
24
+ },
16
25
  } as const;
@@ -1,4 +1,6 @@
1
- export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME || 'My App';
1
+ import { env } from '@/lib/env';
2
+
3
+ export const APP_NAME = env.NEXT_PUBLIC_APP_NAME;
2
4
 
3
5
  export const PAGINATION = {
4
6
  DEFAULT_PAGE: 1,
@@ -2,8 +2,14 @@ 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',
9
+ ADMIN: {
10
+ DASHBOARD: '/admin',
11
+ USERS: '/admin/users',
12
+ USER_DETAIL: (userId: string) => `/admin/users/${userId}`,
13
+ SESSIONS: '/admin/sessions',
14
+ },
9
15
  } as const;
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+
3
+ // Every field MUST have a .default() so the dev fallback (envSchema.parse({}))
4
+ // can produce valid defaults when env vars are missing in development.
5
+ const envSchema = z.object({
6
+ NEXT_PUBLIC_API_BASE_URL: z
7
+ .string()
8
+ .url('NEXT_PUBLIC_API_BASE_URL must be a valid URL')
9
+ .default('http://localhost:8000/api/v1'),
10
+ NEXT_PUBLIC_APP_NAME: z.string().default('My App'),
11
+ });
12
+
13
+ function validateEnv(): z.infer<typeof envSchema> {
14
+ const result = envSchema.safeParse({
15
+ NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
16
+ NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
17
+ });
18
+
19
+ if (!result.success) {
20
+ const formatted = result.error.issues
21
+ .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
22
+ .join('\n');
23
+
24
+ console.error(`\n❌ Invalid environment variables:\n${formatted}\n`);
25
+
26
+ // In production, fail hard. In development, warn but continue with defaults.
27
+ if (process.env.NODE_ENV === 'production') {
28
+ throw new Error('Invalid environment variables');
29
+ }
30
+ }
31
+
32
+ return result.success ? result.data : envSchema.parse({});
33
+ }
34
+
35
+ export const env = validateEnv();
@@ -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;