create-tigra 2.6.5 → 2.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/template/_claude/rules/client/01-project-structure.md +2 -5
  3. package/template/_claude/rules/client/04-design-system.md +48 -43
  4. package/template/_claude/rules/client/core.md +2 -2
  5. package/template/client/src/app/globals.css +12 -12
  6. package/template/client/src/app/layout.tsx +1 -1
  7. package/template/client/src/app/page.tsx +5 -5
  8. package/template/client/src/app/providers.tsx +1 -1
  9. package/template/client/src/components/common/ThemeToggle.tsx +59 -0
  10. package/template/client/src/features/admin/hooks/useAdminSessions.ts +68 -0
  11. package/template/client/src/features/admin/hooks/useAdminStats.ts +27 -0
  12. package/template/client/src/features/admin/hooks/useAdminUsers.ts +132 -0
  13. package/template/client/src/features/admin/services/admin.service.ts +94 -0
  14. package/template/client/src/features/admin/types/admin.types.ts +65 -0
  15. package/template/client/src/features/auth/components/AuthInitializer.tsx +18 -1
  16. package/template/client/src/lib/api/axios.config.ts +20 -1
  17. package/template/client/src/lib/constants/api-endpoints.ts +9 -0
  18. package/template/client/src/lib/constants/app.constants.ts +3 -1
  19. package/template/client/src/lib/constants/routes.ts +6 -0
  20. package/template/client/src/lib/env.ts +35 -0
  21. package/template/client/src/styles/themes/default.css +92 -0
  22. package/template/server/package.json +1 -0
  23. package/template/server/postman/collection.json +168 -50
  24. package/template/server/prisma/schema.prisma +2 -0
  25. package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +14 -4
  26. package/template/server/src/libs/prisma.ts +13 -0
  27. package/template/server/src/modules/admin/admin.controller.ts +130 -1
  28. package/template/server/src/modules/admin/admin.repo.ts +289 -0
  29. package/template/server/src/modules/admin/admin.routes.ts +113 -7
  30. package/template/server/src/modules/admin/admin.schemas.ts +49 -0
  31. package/template/server/src/modules/admin/admin.service.ts +154 -0
  32. package/template/server/src/modules/auth/auth.repo.ts +5 -18
  33. package/template/server/src/modules/auth/auth.service.ts +20 -28
  34. package/template/server/src/modules/auth/session.repo.ts +10 -5
  35. package/template/client/src/components/common/ThemeSwitcher.tsx +0 -112
  36. package/template/client/src/styles/themes/electric-indigo.css +0 -90
  37. package/template/client/src/styles/themes/ocean-teal.css +0 -90
  38. package/template/client/src/styles/themes/rose-pink.css +0 -90
  39. package/template/client/src/styles/themes/warm-orange.css +0 -90
@@ -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
+ }
@@ -12,6 +12,11 @@ import { setUser, setInitialized } from '../store/authSlice';
12
12
 
13
13
  const PROTECTED_PATHS: string[] = [ROUTES.DASHBOARD, ROUTES.PROFILE, '/admin'];
14
14
 
15
+ // Auth pages where getMe() should never fire — there is no session to hydrate
16
+ // on login/register/reset-password, and calling getMe() here would trigger the
17
+ // 401 → refresh → fail → redirect chain for no reason.
18
+ const AUTH_PATHS: string[] = [ROUTES.LOGIN, ROUTES.REGISTER, ROUTES.RESET_PASSWORD, ROUTES.VERIFY_EMAIL];
19
+
15
20
  interface AuthInitializerProps {
16
21
  children: React.ReactNode;
17
22
  }
@@ -20,6 +25,10 @@ function isProtectedPath(pathname: string): boolean {
20
25
  return PROTECTED_PATHS.some((path) => pathname.startsWith(path));
21
26
  }
22
27
 
28
+ function isAuthPage(pathname: string): boolean {
29
+ return AUTH_PATHS.some((path) => pathname.startsWith(path));
30
+ }
31
+
23
32
  export const AuthInitializer = ({ children }: AuthInitializerProps): React.ReactElement => {
24
33
  const dispatch = useAppDispatch();
25
34
  const pathname = usePathname();
@@ -27,7 +36,15 @@ export const AuthInitializer = ({ children }: AuthInitializerProps): React.React
27
36
  const { isAuthenticated, isLoggingOut } = useAppSelector((state) => state.auth);
28
37
 
29
38
  useEffect(() => {
30
- // On public pages, skip auth hydration just mark as initialized
39
+ // On auth pages (login, register, etc.), never call getMe().
40
+ // There is no session to hydrate, and a 401 here would trigger
41
+ // the refresh → fail → redirect chain, causing an infinite loop.
42
+ if (isAuthPage(pathname)) {
43
+ dispatch(setInitialized());
44
+ return;
45
+ }
46
+
47
+ // On other public pages, skip auth hydration — just mark as initialized
31
48
  if (!isProtectedPath(pathname)) {
32
49
  dispatch(setInitialized());
33
50
  return;
@@ -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());
@@ -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,
@@ -6,4 +6,10 @@ export const ROUTES = {
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();
@@ -0,0 +1,92 @@
1
+ /*
2
+ * Default Theme — Claude-inspired warm palette
3
+ * Accent: Terracotta orange
4
+ * Vibe: Earthy, warm, approachable
5
+ *
6
+ * This is the single theme file. Colors use HEX values.
7
+ * Light mode: :root selectors. Dark mode: .dark selectors.
8
+ * To customize: edit the HEX values below.
9
+ */
10
+
11
+ :root {
12
+ --radius: 0.625rem;
13
+ /* Warm cream background with terracotta orange accent */
14
+ --background: #f4f3ee;
15
+ --foreground: #1a170f;
16
+ --card: #ffffff;
17
+ --card-foreground: #1a170f;
18
+ --popover: #ffffff;
19
+ --popover-foreground: #1a170f;
20
+ --primary: #c15f3c;
21
+ --primary-foreground: #ffffff;
22
+ --secondary: #ebeae3;
23
+ --secondary-foreground: #302e25;
24
+ --muted: #ebeae3;
25
+ --muted-foreground: #b1ada1;
26
+ --accent: #ebeae3;
27
+ --accent-foreground: #302e25;
28
+ --destructive: #e7000b;
29
+ --border: #deddd4;
30
+ --input: #deddd4;
31
+ --ring: #c15f3c;
32
+ --success: #008339;
33
+ --success-foreground: #ffffff;
34
+ --warning: #e99b2a;
35
+ --warning-foreground: #242119;
36
+ --info: #0079bf;
37
+ --info-foreground: #ffffff;
38
+ --chart-1: #c15f3c;
39
+ --chart-2: #009689;
40
+ --chart-3: #104e64;
41
+ --chart-4: #ebc065;
42
+ --chart-5: #b1ada1;
43
+ --sidebar: #f0efe9;
44
+ --sidebar-foreground: #1a170f;
45
+ --sidebar-primary: #302e25;
46
+ --sidebar-primary-foreground: #f4f3ee;
47
+ --sidebar-accent: #ebeae3;
48
+ --sidebar-accent-foreground: #302e25;
49
+ --sidebar-border: #deddd4;
50
+ --sidebar-ring: #c15f3c;
51
+ }
52
+
53
+ .dark {
54
+ /* Dark mode: inverted with same warm undertone */
55
+ --background: #15130d;
56
+ --foreground: #e9e8e3;
57
+ --card: #201e18;
58
+ --card-foreground: #e9e8e3;
59
+ --popover: #201e18;
60
+ --popover-foreground: #e9e8e3;
61
+ --primary: #d6724f;
62
+ --primary-foreground: #15130d;
63
+ --secondary: #2b2922;
64
+ --secondary-foreground: #e9e8e3;
65
+ --muted: #2b2922;
66
+ --muted-foreground: #928f85;
67
+ --accent: #2b2922;
68
+ --accent-foreground: #e9e8e3;
69
+ --destructive: #ff6467;
70
+ --border: rgba(255, 255, 250, 0.12);
71
+ --input: rgba(255, 255, 250, 0.15);
72
+ --ring: #d6724f;
73
+ --success: #009c50;
74
+ --success-foreground: #ffffff;
75
+ --warning: #faab3f;
76
+ --warning-foreground: #242119;
77
+ --info: #0099e0;
78
+ --info-foreground: #ffffff;
79
+ --chart-1: #d6724f;
80
+ --chart-2: #00bc7d;
81
+ --chart-3: #e5a658;
82
+ --chart-4: #a066df;
83
+ --chart-5: #e25969;
84
+ --sidebar: #201e18;
85
+ --sidebar-foreground: #e9e8e3;
86
+ --sidebar-primary: #d6724f;
87
+ --sidebar-primary-foreground: #e9e8e3;
88
+ --sidebar-accent: #2b2922;
89
+ --sidebar-accent-foreground: #e9e8e3;
90
+ --sidebar-border: rgba(255, 255, 250, 0.12);
91
+ --sidebar-ring: #d6724f;
92
+ }
@@ -52,6 +52,7 @@
52
52
  },
53
53
  "overrides": {
54
54
  "bn.js": ">=5.2.3",
55
+ "flatted": ">=3.4.2",
55
56
  "@typescript-eslint/typescript-estree": {
56
57
  "minimatch": ">=10.2.1"
57
58
  }