create-tigra 2.6.0 → 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 (42) hide show
  1. package/package.json +1 -1
  2. package/template/_claude/commands/create-client.md +1 -1
  3. package/template/_claude/rules/client/01-project-structure.md +4 -5
  4. package/template/_claude/rules/client/04-design-system.md +143 -44
  5. package/template/_claude/rules/client/core.md +8 -7
  6. package/template/client/README.md +1 -1
  7. package/template/client/src/app/globals.css +27 -14
  8. package/template/client/src/app/layout.tsx +7 -7
  9. package/template/client/src/app/page.tsx +5 -5
  10. package/template/client/src/app/providers.tsx +1 -1
  11. package/template/client/src/components/common/ThemeToggle.tsx +59 -0
  12. package/template/client/src/features/admin/hooks/useAdminSessions.ts +68 -0
  13. package/template/client/src/features/admin/hooks/useAdminStats.ts +27 -0
  14. package/template/client/src/features/admin/hooks/useAdminUsers.ts +132 -0
  15. package/template/client/src/features/admin/services/admin.service.ts +94 -0
  16. package/template/client/src/features/admin/types/admin.types.ts +65 -0
  17. package/template/client/src/features/auth/components/AuthInitializer.tsx +18 -1
  18. package/template/client/src/lib/api/axios.config.ts +20 -1
  19. package/template/client/src/lib/constants/api-endpoints.ts +9 -0
  20. package/template/client/src/lib/constants/app.constants.ts +3 -1
  21. package/template/client/src/lib/constants/routes.ts +6 -0
  22. package/template/client/src/lib/env.ts +35 -0
  23. package/template/client/src/styles/fonts/inter-jetbrains.css +16 -0
  24. package/template/client/src/styles/themes/default.css +92 -0
  25. package/template/server/package.json +1 -0
  26. package/template/server/postman/collection.json +168 -50
  27. package/template/server/prisma/schema.prisma +2 -0
  28. package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +14 -4
  29. package/template/server/src/libs/prisma.ts +13 -0
  30. package/template/server/src/modules/admin/admin.controller.ts +130 -1
  31. package/template/server/src/modules/admin/admin.repo.ts +289 -0
  32. package/template/server/src/modules/admin/admin.routes.ts +113 -7
  33. package/template/server/src/modules/admin/admin.schemas.ts +49 -0
  34. package/template/server/src/modules/admin/admin.service.ts +154 -0
  35. package/template/server/src/modules/auth/auth.repo.ts +5 -18
  36. package/template/server/src/modules/auth/auth.service.ts +20 -28
  37. package/template/server/src/modules/auth/session.repo.ts +10 -5
  38. package/template/client/src/components/common/ThemeSwitcher.tsx +0 -112
  39. package/template/client/src/styles/themes/electric-indigo.css +0 -90
  40. package/template/client/src/styles/themes/ocean-teal.css +0 -90
  41. package/template/client/src/styles/themes/rose-pink.css +0 -90
  42. package/template/client/src/styles/themes/warm-orange.css +0 -90
@@ -2,38 +2,28 @@
2
2
  "info": {
3
3
  "name": "{{PROJECT_DISPLAY_NAME}} API",
4
4
  "_postman_id": "{{PROJECT_NAME}}-server-collection",
5
- "description": "API collection for {{PROJECT_DISPLAY_NAME}}.\n\nAll authenticated requests inherit Bearer Token auth from the collection root.\nLogin/Register requests auto-set tokens via test scripts.",
5
+ "description": "API collection for {{PROJECT_DISPLAY_NAME}}.\n\nAuthentication is cookie-based (httpOnly). After Login or Register, Postman's cookie jar automatically stores access_token and refresh_token cookies. All subsequent requests send these cookies automatically — no manual token management needed.",
6
6
  "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
7
7
  },
8
- "auth": {
9
- "type": "bearer",
10
- "bearer": [
11
- {
12
- "key": "token",
13
- "value": "{{accessToken}}",
14
- "type": "string"
15
- }
16
- ]
17
- },
18
8
  "variable": [
19
9
  {
20
10
  "key": "baseUrl",
21
11
  "value": "http://localhost:8000/api/v1"
22
12
  },
23
13
  {
24
- "key": "accessToken",
14
+ "key": "userId",
25
15
  "value": ""
26
16
  },
27
17
  {
28
- "key": "refreshToken",
18
+ "key": "targetUserId",
29
19
  "value": ""
30
20
  },
31
21
  {
32
- "key": "userId",
22
+ "key": "sessionId",
33
23
  "value": ""
34
24
  },
35
25
  {
36
- "key": "targetUserId",
26
+ "key": "blockedIp",
37
27
  "value": ""
38
28
  }
39
29
  ],
@@ -156,16 +146,6 @@
156
146
  {
157
147
  "name": "Get Me",
158
148
  "request": {
159
- "auth": {
160
- "type": "bearer",
161
- "bearer": [
162
- {
163
- "key": "token",
164
- "value": "{{accessToken}}",
165
- "type": "string"
166
- }
167
- ]
168
- },
169
149
  "method": "GET",
170
150
  "header": [],
171
151
  "url": {
@@ -173,23 +153,13 @@
173
153
  "host": ["{{baseUrl}}"],
174
154
  "path": ["auth", "me"]
175
155
  },
176
- "description": "Get the currently authenticated user."
156
+ "description": "Get the currently authenticated user. Auth via httpOnly access_token cookie (sent automatically after login)."
177
157
  },
178
158
  "response": []
179
159
  },
180
160
  {
181
161
  "name": "Get Sessions",
182
162
  "request": {
183
- "auth": {
184
- "type": "bearer",
185
- "bearer": [
186
- {
187
- "key": "token",
188
- "value": "{{accessToken}}",
189
- "type": "string"
190
- }
191
- ]
192
- },
193
163
  "method": "GET",
194
164
  "header": [],
195
165
  "url": {
@@ -197,23 +167,13 @@
197
167
  "host": ["{{baseUrl}}"],
198
168
  "path": ["auth", "sessions"]
199
169
  },
200
- "description": "Get all active sessions for the current user."
170
+ "description": "Get all active sessions for the current user. Auth via httpOnly access_token cookie."
201
171
  },
202
172
  "response": []
203
173
  },
204
174
  {
205
175
  "name": "Logout All Sessions",
206
176
  "request": {
207
- "auth": {
208
- "type": "bearer",
209
- "bearer": [
210
- {
211
- "key": "token",
212
- "value": "{{accessToken}}",
213
- "type": "string"
214
- }
215
- ]
216
- },
217
177
  "method": "POST",
218
178
  "header": [],
219
179
  "url": {
@@ -221,7 +181,7 @@
221
181
  "host": ["{{baseUrl}}"],
222
182
  "path": ["auth", "logout-all"]
223
183
  },
224
- "description": "Logout from all sessions and invalidate all refresh tokens."
184
+ "description": "Logout from all sessions and invalidate all refresh tokens. Auth via httpOnly access_token cookie."
225
185
  },
226
186
  "response": []
227
187
  }
@@ -502,6 +462,22 @@
502
462
  },
503
463
  "description": "Permanently block an IP address. Persisted to database and cached in Redis. Blocked IPs receive 403 IP_BLOCKED on all requests. Optional reason field for audit trail."
504
464
  },
465
+ "event": [
466
+ {
467
+ "listen": "test",
468
+ "script": {
469
+ "type": "text/javascript",
470
+ "exec": [
471
+ "if (pm.response.code === 200 || pm.response.code === 201) {",
472
+ " const body = JSON.parse(pm.request.body.raw);",
473
+ " if (body && body.ip) {",
474
+ " pm.collectionVariables.set('blockedIp', body.ip);",
475
+ " }",
476
+ "}"
477
+ ]
478
+ }
479
+ }
480
+ ],
505
481
  "response": []
506
482
  },
507
483
  {
@@ -510,13 +486,155 @@
510
486
  "method": "DELETE",
511
487
  "header": [],
512
488
  "url": {
513
- "raw": "{{baseUrl}}/admin/blocked-ips/1.2.3.4",
489
+ "raw": "{{baseUrl}}/admin/blocked-ips/{{blockedIp}}",
514
490
  "host": ["{{baseUrl}}"],
515
- "path": ["admin", "blocked-ips", "1.2.3.4"]
491
+ "path": ["admin", "blocked-ips", "{{blockedIp}}"]
516
492
  },
517
493
  "description": "Remove an IP from the permanent block list. Also removes it from auto-blocks if present."
518
494
  },
519
495
  "response": []
496
+ },
497
+ {
498
+ "name": "Dashboard Stats",
499
+ "request": {
500
+ "method": "GET",
501
+ "header": [],
502
+ "url": {
503
+ "raw": "{{baseUrl}}/admin/stats",
504
+ "host": ["{{baseUrl}}"],
505
+ "path": ["admin", "stats"]
506
+ },
507
+ "description": "Get dashboard statistics: total users, active users, admin count, recent signups (7 days), active sessions."
508
+ },
509
+ "response": []
510
+ },
511
+ {
512
+ "name": "Users",
513
+ "description": "Admin user listing, detail, status, and role management.",
514
+ "item": [
515
+ {
516
+ "name": "List Users",
517
+ "request": {
518
+ "method": "GET",
519
+ "header": [],
520
+ "url": {
521
+ "raw": "{{baseUrl}}/admin/users?page=1&limit=10",
522
+ "host": ["{{baseUrl}}"],
523
+ "path": ["admin", "users"],
524
+ "query": [
525
+ { "key": "page", "value": "1" },
526
+ { "key": "limit", "value": "10" },
527
+ { "key": "search", "value": "", "disabled": true },
528
+ { "key": "role", "value": "USER", "disabled": true },
529
+ { "key": "isActive", "value": "true", "disabled": true },
530
+ { "key": "sortBy", "value": "createdAt", "disabled": true },
531
+ { "key": "sortOrder", "value": "desc", "disabled": true }
532
+ ]
533
+ },
534
+ "description": "Get paginated list of users. Supports search (email, firstName, lastName), role filter, isActive filter, and sorting."
535
+ },
536
+ "response": []
537
+ },
538
+ {
539
+ "name": "Get User Detail",
540
+ "request": {
541
+ "method": "GET",
542
+ "header": [],
543
+ "url": {
544
+ "raw": "{{baseUrl}}/admin/users/{{targetUserId}}",
545
+ "host": ["{{baseUrl}}"],
546
+ "path": ["admin", "users", "{{targetUserId}}"]
547
+ },
548
+ "description": "Get detailed user info including active session count and last login timestamp."
549
+ },
550
+ "response": []
551
+ },
552
+ {
553
+ "name": "Toggle User Status",
554
+ "request": {
555
+ "method": "PATCH",
556
+ "header": [
557
+ {
558
+ "key": "Content-Type",
559
+ "value": "application/json"
560
+ }
561
+ ],
562
+ "body": {
563
+ "mode": "raw",
564
+ "raw": "{\n \"isActive\": false\n}"
565
+ },
566
+ "url": {
567
+ "raw": "{{baseUrl}}/admin/users/{{targetUserId}}/status",
568
+ "host": ["{{baseUrl}}"],
569
+ "path": ["admin", "users", "{{targetUserId}}", "status"]
570
+ },
571
+ "description": "Activate or deactivate a user. Deactivating also invalidates all their sessions and refresh tokens."
572
+ },
573
+ "response": []
574
+ },
575
+ {
576
+ "name": "Change User Role",
577
+ "request": {
578
+ "method": "PATCH",
579
+ "header": [
580
+ {
581
+ "key": "Content-Type",
582
+ "value": "application/json"
583
+ }
584
+ ],
585
+ "body": {
586
+ "mode": "raw",
587
+ "raw": "{\n \"role\": \"ADMIN\"\n}"
588
+ },
589
+ "url": {
590
+ "raw": "{{baseUrl}}/admin/users/{{targetUserId}}/role",
591
+ "host": ["{{baseUrl}}"],
592
+ "path": ["admin", "users", "{{targetUserId}}", "role"]
593
+ },
594
+ "description": "Change a user's role. Cannot change your own role (self-protection). Demoting from ADMIN invalidates their sessions."
595
+ },
596
+ "response": []
597
+ }
598
+ ]
599
+ },
600
+ {
601
+ "name": "Sessions",
602
+ "description": "Admin session management. View and force-expire active sessions.",
603
+ "item": [
604
+ {
605
+ "name": "List Sessions",
606
+ "request": {
607
+ "method": "GET",
608
+ "header": [],
609
+ "url": {
610
+ "raw": "{{baseUrl}}/admin/sessions?page=1&limit=10",
611
+ "host": ["{{baseUrl}}"],
612
+ "path": ["admin", "sessions"],
613
+ "query": [
614
+ { "key": "page", "value": "1" },
615
+ { "key": "limit", "value": "10" },
616
+ { "key": "userId", "value": "", "disabled": true }
617
+ ]
618
+ },
619
+ "description": "Get paginated list of all active sessions. Optionally filter by userId. Includes user info (id, email, name) for each session."
620
+ },
621
+ "response": []
622
+ },
623
+ {
624
+ "name": "Force Expire Session",
625
+ "request": {
626
+ "method": "DELETE",
627
+ "header": [],
628
+ "url": {
629
+ "raw": "{{baseUrl}}/admin/sessions/{{sessionId}}",
630
+ "host": ["{{baseUrl}}"],
631
+ "path": ["admin", "sessions", "{{sessionId}}"]
632
+ },
633
+ "description": "Force-expire a specific session. Also deletes associated refresh tokens."
634
+ },
635
+ "response": []
636
+ }
637
+ ]
520
638
  }
521
639
  ]
522
640
  }
@@ -46,6 +46,7 @@ model RefreshToken {
46
46
  id String @id @default(uuid())
47
47
  token String @unique @db.VarChar(500)
48
48
  userId String
49
+ sessionId String? // Links token to its session for direct lookup on logout
49
50
  expiresAt DateTime
50
51
  createdAt DateTime @default(now())
51
52
 
@@ -56,6 +57,7 @@ model RefreshToken {
56
57
  @@index([token])
57
58
  @@index([userId, expiresAt]) // Compound index for efficient token cleanup queries
58
59
  @@index([expiresAt]) // For expired token cleanup cron jobs
60
+ @@index([sessionId]) // For deleting tokens by session (admin force-expire)
59
61
  @@map("refresh_tokens")
60
62
  }
61
63
 
@@ -48,14 +48,24 @@ export function startCleanupDeletedAccountsJob(app: FastifyInstance): void {
48
48
 
49
49
  for (const user of usersToDelete) {
50
50
  try {
51
- // Delete all user media from disk (no-op if dir doesn't exist)
52
- await fileStorageService.deleteUserMedia(user.id);
53
-
54
- // Hard delete user record (cascades to RefreshToken + Session)
51
+ // Hard delete user record first (cascades to RefreshToken + Session).
52
+ // DB deletion is the critical operation — if it succeeds but file cleanup
53
+ // fails, we only have orphaned files (harmless, cleanable later).
54
+ // The reverse (files deleted, DB intact) would leave a dangling user record.
55
55
  await prisma.user.delete({
56
56
  where: { id: user.id },
57
57
  });
58
58
 
59
+ // Delete all user media from disk (no-op if dir doesn't exist)
60
+ try {
61
+ await fileStorageService.deleteUserMedia(user.id);
62
+ } catch (fileError) {
63
+ logger.warn(
64
+ { err: fileError, userId: user.id },
65
+ 'User record purged but file cleanup failed — orphaned files may remain',
66
+ );
67
+ }
68
+
59
69
  purgedCount++;
60
70
  } catch (error) {
61
71
  logger.error(
@@ -50,6 +50,19 @@ if (env.NODE_ENV !== 'production') {
50
50
  globalForPrisma.prisma = prisma;
51
51
  }
52
52
 
53
+ /**
54
+ * Check if a caught error is Prisma's "record not found" error (P2025).
55
+ * Use in delete/update operations where the record may have already been
56
+ * removed by a concurrent request or cleanup job.
57
+ */
58
+ export function isPrismaNotFound(error: unknown): boolean {
59
+ return (
60
+ error instanceof Error &&
61
+ 'code' in error &&
62
+ (error as { code: string }).code === 'P2025'
63
+ );
64
+ }
65
+
53
66
  export async function testDatabaseConnection(): Promise<boolean> {
54
67
  try {
55
68
  await prisma.$queryRaw`SELECT 1`;
@@ -1,10 +1,23 @@
1
1
  import type { FastifyRequest, FastifyReply } from 'fastify';
2
2
  import { successResponse } from '@shared/responses/successResponse.js';
3
+ import { paginatedResponse } from '@shared/responses/paginatedResponse.js';
3
4
  import { ValidationError } from '@shared/errors/errors.js';
4
5
  import { blockIp, unblockIp, getBlockedIps } from '@libs/ip-block.js';
5
- import { blockIpSchema } from './admin.schemas.js';
6
+ import { adminService } from './admin.service.js';
7
+ import {
8
+ blockIpSchema,
9
+ getUsersQuerySchema,
10
+ userIdParamsSchema,
11
+ updateUserStatusSchema,
12
+ updateUserRoleSchema,
13
+ getSessionsQuerySchema,
14
+ sessionIdParamsSchema,
15
+ } from './admin.schemas.js';
6
16
 
7
17
  import type { BlockIpInput, UnblockIpParams } from './admin.schemas.js';
18
+ import type { GetUsersQuery, UserIdParams, UpdateUserStatusInput, UpdateUserRoleInput, GetSessionsQuery, SessionIdParams } from './admin.schemas.js';
19
+
20
+ // ─── IP Blocking ────────────────────────────────────────────────────────────
8
21
 
9
22
  export async function listBlockedIps(
10
23
  _request: FastifyRequest,
@@ -34,3 +47,119 @@ export async function unblockIpHandler(
34
47
  await unblockIp(ip);
35
48
  reply.send(successResponse('IP unblocked successfully', { ip }));
36
49
  }
50
+
51
+ // ─── Dashboard Stats ────────────────────────────────────────────────────────
52
+
53
+ export async function getDashboardStats(
54
+ _request: FastifyRequest,
55
+ reply: FastifyReply,
56
+ ): Promise<void> {
57
+ const stats = await adminService.getDashboardStats();
58
+ reply.send(successResponse('Dashboard stats retrieved', stats));
59
+ }
60
+
61
+ // ─── User Management ────────────────────────────────────────────────────────
62
+
63
+ export async function getUsers(
64
+ request: FastifyRequest<{ Querystring: GetUsersQuery }>,
65
+ reply: FastifyReply,
66
+ ): Promise<void> {
67
+ const parsed = getUsersQuerySchema.safeParse(request.query);
68
+ if (!parsed.success) {
69
+ throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid query parameters');
70
+ }
71
+
72
+ const { page, limit } = parsed.data;
73
+ const { items, totalItems } = await adminService.getUsers(parsed.data);
74
+
75
+ reply.send(paginatedResponse('Users retrieved', items, page, limit, totalItems));
76
+ }
77
+
78
+ export async function getUserDetail(
79
+ request: FastifyRequest<{ Params: UserIdParams }>,
80
+ reply: FastifyReply,
81
+ ): Promise<void> {
82
+ const parsed = userIdParamsSchema.safeParse(request.params);
83
+ if (!parsed.success) {
84
+ throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid user ID');
85
+ }
86
+
87
+ const user = await adminService.getUserDetail(parsed.data.userId);
88
+ reply.send(successResponse('User details retrieved', user));
89
+ }
90
+
91
+ export async function updateUserStatus(
92
+ request: FastifyRequest<{ Params: UserIdParams; Body: UpdateUserStatusInput }>,
93
+ reply: FastifyReply,
94
+ ): Promise<void> {
95
+ const paramsParsed = userIdParamsSchema.safeParse(request.params);
96
+ if (!paramsParsed.success) {
97
+ throw new ValidationError(paramsParsed.error.issues[0]?.message ?? 'Invalid user ID');
98
+ }
99
+
100
+ const bodyParsed = updateUserStatusSchema.safeParse(request.body);
101
+ if (!bodyParsed.success) {
102
+ throw new ValidationError(bodyParsed.error.issues[0]?.message ?? 'Invalid status');
103
+ }
104
+
105
+ const user = await adminService.toggleUserStatus(
106
+ paramsParsed.data.userId,
107
+ bodyParsed.data.isActive,
108
+ );
109
+
110
+ const action = bodyParsed.data.isActive ? 'activated' : 'deactivated';
111
+ reply.send(successResponse(`User ${action} successfully`, user));
112
+ }
113
+
114
+ export async function updateUserRole(
115
+ request: FastifyRequest<{ Params: UserIdParams; Body: UpdateUserRoleInput }>,
116
+ reply: FastifyReply,
117
+ ): Promise<void> {
118
+ const paramsParsed = userIdParamsSchema.safeParse(request.params);
119
+ if (!paramsParsed.success) {
120
+ throw new ValidationError(paramsParsed.error.issues[0]?.message ?? 'Invalid user ID');
121
+ }
122
+
123
+ const bodyParsed = updateUserRoleSchema.safeParse(request.body);
124
+ if (!bodyParsed.success) {
125
+ throw new ValidationError(bodyParsed.error.issues[0]?.message ?? 'Invalid role');
126
+ }
127
+
128
+ const user = await adminService.changeUserRole(
129
+ paramsParsed.data.userId,
130
+ bodyParsed.data.role,
131
+ request.user.userId,
132
+ );
133
+
134
+ reply.send(successResponse('User role updated successfully', user));
135
+ }
136
+
137
+ // ─── Session Management ─────────────────────────────────────────────────────
138
+
139
+ export async function getAllSessions(
140
+ request: FastifyRequest<{ Querystring: GetSessionsQuery }>,
141
+ reply: FastifyReply,
142
+ ): Promise<void> {
143
+ const parsed = getSessionsQuerySchema.safeParse(request.query);
144
+ if (!parsed.success) {
145
+ throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid query parameters');
146
+ }
147
+
148
+ const { page, limit } = parsed.data;
149
+ const { items, totalItems } = await adminService.getAllSessions(parsed.data);
150
+
151
+ reply.send(paginatedResponse('Sessions retrieved', items, page, limit, totalItems));
152
+ }
153
+
154
+ export async function forceExpireSession(
155
+ request: FastifyRequest<{ Params: SessionIdParams }>,
156
+ reply: FastifyReply,
157
+ ): Promise<void> {
158
+ const parsed = sessionIdParamsSchema.safeParse(request.params);
159
+ if (!parsed.success) {
160
+ throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid session ID');
161
+ }
162
+
163
+ await adminService.forceExpireSession(parsed.data.sessionId);
164
+ reply.send(successResponse('Session expired successfully', null));
165
+ }