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.
- package/package.json +1 -1
- package/template/_claude/commands/create-client.md +1 -1
- package/template/_claude/rules/client/01-project-structure.md +4 -5
- package/template/_claude/rules/client/04-design-system.md +143 -44
- package/template/_claude/rules/client/core.md +8 -7
- package/template/client/README.md +1 -1
- package/template/client/src/app/globals.css +27 -14
- package/template/client/src/app/layout.tsx +7 -7
- package/template/client/src/app/page.tsx +5 -5
- package/template/client/src/app/providers.tsx +1 -1
- package/template/client/src/components/common/ThemeToggle.tsx +59 -0
- package/template/client/src/features/admin/hooks/useAdminSessions.ts +68 -0
- package/template/client/src/features/admin/hooks/useAdminStats.ts +27 -0
- package/template/client/src/features/admin/hooks/useAdminUsers.ts +132 -0
- package/template/client/src/features/admin/services/admin.service.ts +94 -0
- package/template/client/src/features/admin/types/admin.types.ts +65 -0
- package/template/client/src/features/auth/components/AuthInitializer.tsx +18 -1
- package/template/client/src/lib/api/axios.config.ts +20 -1
- package/template/client/src/lib/constants/api-endpoints.ts +9 -0
- package/template/client/src/lib/constants/app.constants.ts +3 -1
- package/template/client/src/lib/constants/routes.ts +6 -0
- package/template/client/src/lib/env.ts +35 -0
- package/template/client/src/styles/fonts/inter-jetbrains.css +16 -0
- package/template/client/src/styles/themes/default.css +92 -0
- package/template/server/package.json +1 -0
- package/template/server/postman/collection.json +168 -50
- package/template/server/prisma/schema.prisma +2 -0
- package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +14 -4
- package/template/server/src/libs/prisma.ts +13 -0
- package/template/server/src/modules/admin/admin.controller.ts +130 -1
- package/template/server/src/modules/admin/admin.repo.ts +289 -0
- package/template/server/src/modules/admin/admin.routes.ts +113 -7
- package/template/server/src/modules/admin/admin.schemas.ts +49 -0
- package/template/server/src/modules/admin/admin.service.ts +154 -0
- package/template/server/src/modules/auth/auth.repo.ts +5 -18
- package/template/server/src/modules/auth/auth.service.ts +20 -28
- package/template/server/src/modules/auth/session.repo.ts +10 -5
- package/template/client/src/components/common/ThemeSwitcher.tsx +0 -112
- package/template/client/src/styles/themes/electric-indigo.css +0 -90
- package/template/client/src/styles/themes/ocean-teal.css +0 -90
- package/template/client/src/styles/themes/rose-pink.css +0 -90
- package/template/client/src/styles/themes/warm-orange.css +0 -90
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin Repository
|
|
3
|
+
*
|
|
4
|
+
* Data access layer for admin-specific queries.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { prisma, isPrismaNotFound } from '@libs/prisma.js';
|
|
8
|
+
|
|
9
|
+
import type { UserRole } from '@shared/types/index.js';
|
|
10
|
+
|
|
11
|
+
// ─── Select Shapes ──────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const adminUserSelect = {
|
|
14
|
+
id: true,
|
|
15
|
+
email: true,
|
|
16
|
+
firstName: true,
|
|
17
|
+
lastName: true,
|
|
18
|
+
role: true,
|
|
19
|
+
avatarUrl: true,
|
|
20
|
+
isActive: true,
|
|
21
|
+
deletedAt: true,
|
|
22
|
+
failedLoginAttempts: true,
|
|
23
|
+
lockedUntil: true,
|
|
24
|
+
createdAt: true,
|
|
25
|
+
updatedAt: true,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
const sessionUserSelect = {
|
|
29
|
+
id: true,
|
|
30
|
+
email: true,
|
|
31
|
+
firstName: true,
|
|
32
|
+
lastName: true,
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export type AdminUser = {
|
|
38
|
+
id: string;
|
|
39
|
+
email: string;
|
|
40
|
+
firstName: string;
|
|
41
|
+
lastName: string;
|
|
42
|
+
role: UserRole;
|
|
43
|
+
avatarUrl: string | null;
|
|
44
|
+
isActive: boolean;
|
|
45
|
+
deletedAt: Date | null;
|
|
46
|
+
failedLoginAttempts: number;
|
|
47
|
+
lockedUntil: Date | null;
|
|
48
|
+
createdAt: Date;
|
|
49
|
+
updatedAt: Date;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type AdminUserDetail = AdminUser & {
|
|
53
|
+
sessionCount: number;
|
|
54
|
+
lastLogin: Date | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type AdminSession = {
|
|
58
|
+
id: string;
|
|
59
|
+
userId: string;
|
|
60
|
+
deviceInfo: string | null;
|
|
61
|
+
ipAddress: string | null;
|
|
62
|
+
lastActiveAt: Date;
|
|
63
|
+
expiresAt: Date;
|
|
64
|
+
createdAt: Date;
|
|
65
|
+
user: {
|
|
66
|
+
id: string;
|
|
67
|
+
email: string;
|
|
68
|
+
firstName: string;
|
|
69
|
+
lastName: string;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type DashboardStats = {
|
|
74
|
+
totalUsers: number;
|
|
75
|
+
activeUsers: number;
|
|
76
|
+
adminCount: number;
|
|
77
|
+
recentSignups: number;
|
|
78
|
+
activeSessions: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ─── Repository ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
class AdminRepository {
|
|
84
|
+
/**
|
|
85
|
+
* Get paginated list of users with optional filters
|
|
86
|
+
*/
|
|
87
|
+
async getUsers(params: {
|
|
88
|
+
page: number;
|
|
89
|
+
limit: number;
|
|
90
|
+
search?: string;
|
|
91
|
+
role?: UserRole;
|
|
92
|
+
isActive?: boolean;
|
|
93
|
+
sortBy: 'createdAt' | 'email' | 'firstName';
|
|
94
|
+
sortOrder: 'asc' | 'desc';
|
|
95
|
+
}): Promise<{ items: AdminUser[]; totalItems: number }> {
|
|
96
|
+
const { page, limit, search, role, isActive, sortBy, sortOrder } = params;
|
|
97
|
+
const offset = (page - 1) * limit;
|
|
98
|
+
|
|
99
|
+
const where: Record<string, unknown> = {
|
|
100
|
+
deletedAt: null,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (search) {
|
|
104
|
+
where.OR = [
|
|
105
|
+
{ email: { contains: search } },
|
|
106
|
+
{ firstName: { contains: search } },
|
|
107
|
+
{ lastName: { contains: search } },
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (role) {
|
|
112
|
+
where.role = role;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (isActive !== undefined) {
|
|
116
|
+
where.isActive = isActive;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const [items, totalItems] = await Promise.all([
|
|
120
|
+
prisma.user.findMany({
|
|
121
|
+
where,
|
|
122
|
+
select: adminUserSelect,
|
|
123
|
+
orderBy: { [sortBy]: sortOrder },
|
|
124
|
+
skip: offset,
|
|
125
|
+
take: limit,
|
|
126
|
+
}),
|
|
127
|
+
prisma.user.count({ where }),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
return { items, totalItems };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Lightweight user lookup for write operations (existence + role check).
|
|
135
|
+
* Use getUserDetail() when you need session count and last login.
|
|
136
|
+
*/
|
|
137
|
+
async findUserById(userId: string): Promise<AdminUser | null> {
|
|
138
|
+
return prisma.user.findUnique({
|
|
139
|
+
where: { id: userId, deletedAt: null },
|
|
140
|
+
select: adminUserSelect,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get detailed user info including session count and last login
|
|
146
|
+
*/
|
|
147
|
+
async getUserDetail(userId: string): Promise<AdminUserDetail | null> {
|
|
148
|
+
const now = new Date();
|
|
149
|
+
|
|
150
|
+
const user = await prisma.user.findUnique({
|
|
151
|
+
where: { id: userId, deletedAt: null },
|
|
152
|
+
select: {
|
|
153
|
+
...adminUserSelect,
|
|
154
|
+
_count: {
|
|
155
|
+
select: {
|
|
156
|
+
sessions: {
|
|
157
|
+
where: { expiresAt: { gt: now } },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (!user) return null;
|
|
165
|
+
|
|
166
|
+
// Get last login from most recent session
|
|
167
|
+
const latestSession = await prisma.session.findFirst({
|
|
168
|
+
where: { userId },
|
|
169
|
+
orderBy: { lastActiveAt: 'desc' },
|
|
170
|
+
select: { lastActiveAt: true },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const { _count, ...userData } = user;
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
...userData,
|
|
177
|
+
sessionCount: _count.sessions,
|
|
178
|
+
lastLogin: latestSession?.lastActiveAt ?? null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Update user active status
|
|
184
|
+
*/
|
|
185
|
+
async updateUserStatus(userId: string, isActive: boolean): Promise<AdminUser> {
|
|
186
|
+
return prisma.user.update({
|
|
187
|
+
where: { id: userId },
|
|
188
|
+
data: { isActive },
|
|
189
|
+
select: adminUserSelect,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Update user role
|
|
195
|
+
*/
|
|
196
|
+
async updateUserRole(userId: string, role: UserRole): Promise<AdminUser> {
|
|
197
|
+
return prisma.user.update({
|
|
198
|
+
where: { id: userId },
|
|
199
|
+
data: { role },
|
|
200
|
+
select: adminUserSelect,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get dashboard statistics
|
|
206
|
+
*/
|
|
207
|
+
async getDashboardStats(): Promise<DashboardStats> {
|
|
208
|
+
const now = new Date();
|
|
209
|
+
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
210
|
+
|
|
211
|
+
const [totalUsers, activeUsers, adminCount, recentSignups, activeSessions] =
|
|
212
|
+
await Promise.all([
|
|
213
|
+
prisma.user.count({ where: { deletedAt: null } }),
|
|
214
|
+
prisma.user.count({ where: { deletedAt: null, isActive: true } }),
|
|
215
|
+
prisma.user.count({ where: { deletedAt: null, role: 'ADMIN' } }),
|
|
216
|
+
prisma.user.count({
|
|
217
|
+
where: { deletedAt: null, createdAt: { gte: sevenDaysAgo } },
|
|
218
|
+
}),
|
|
219
|
+
prisma.session.count({ where: { expiresAt: { gt: now } } }),
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
return { totalUsers, activeUsers, adminCount, recentSignups, activeSessions };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get paginated list of active sessions with user info
|
|
227
|
+
*/
|
|
228
|
+
async getAllSessions(params: {
|
|
229
|
+
page: number;
|
|
230
|
+
limit: number;
|
|
231
|
+
userId?: string;
|
|
232
|
+
}): Promise<{ items: AdminSession[]; totalItems: number }> {
|
|
233
|
+
const { page, limit, userId } = params;
|
|
234
|
+
const offset = (page - 1) * limit;
|
|
235
|
+
const now = new Date();
|
|
236
|
+
|
|
237
|
+
const where: Record<string, unknown> = {
|
|
238
|
+
expiresAt: { gt: now },
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
if (userId) {
|
|
242
|
+
where.userId = userId;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const [items, totalItems] = await Promise.all([
|
|
246
|
+
prisma.session.findMany({
|
|
247
|
+
where,
|
|
248
|
+
include: { user: { select: sessionUserSelect } },
|
|
249
|
+
orderBy: { lastActiveAt: 'desc' },
|
|
250
|
+
skip: offset,
|
|
251
|
+
take: limit,
|
|
252
|
+
}),
|
|
253
|
+
prisma.session.count({ where }),
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
return { items, totalItems };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Get session by ID with user info
|
|
261
|
+
*/
|
|
262
|
+
async getSessionById(sessionId: string): Promise<AdminSession | null> {
|
|
263
|
+
return prisma.session.findUnique({
|
|
264
|
+
where: { id: sessionId },
|
|
265
|
+
include: { user: { select: sessionUserSelect } },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Delete a session (no-op if already deleted)
|
|
271
|
+
*/
|
|
272
|
+
async deleteSession(sessionId: string): Promise<void> {
|
|
273
|
+
try {
|
|
274
|
+
await prisma.session.delete({ where: { id: sessionId } });
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (isPrismaNotFound(error)) return;
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Delete refresh tokens linked to a specific session
|
|
283
|
+
*/
|
|
284
|
+
async deleteRefreshTokensBySessionId(sessionId: string): Promise<void> {
|
|
285
|
+
await prisma.refreshToken.deleteMany({ where: { sessionId } });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export const adminRepository = new AdminRepository();
|
|
@@ -2,13 +2,24 @@ import type { FastifyInstance } from 'fastify';
|
|
|
2
2
|
import { authenticate, authorize } from '@libs/auth.js';
|
|
3
3
|
import { RATE_LIMITS } from '@config/rate-limit.config.js';
|
|
4
4
|
import * as adminController from './admin.controller.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
blockIpSchema,
|
|
7
|
+
unblockIpParamsSchema,
|
|
8
|
+
getUsersQuerySchema,
|
|
9
|
+
userIdParamsSchema,
|
|
10
|
+
updateUserStatusSchema,
|
|
11
|
+
updateUserRoleSchema,
|
|
12
|
+
getSessionsQuerySchema,
|
|
13
|
+
sessionIdParamsSchema,
|
|
14
|
+
} from './admin.schemas.js';
|
|
6
15
|
|
|
7
16
|
export async function adminRoutes(fastify: FastifyInstance): Promise<void> {
|
|
8
17
|
// All admin routes require authentication + ADMIN role
|
|
9
18
|
fastify.addHook('preValidation', authenticate);
|
|
10
19
|
fastify.addHook('preValidation', authorize('ADMIN'));
|
|
11
20
|
|
|
21
|
+
// ─── IP Blocking ────────────────────────────────────────────────────────
|
|
22
|
+
|
|
12
23
|
/**
|
|
13
24
|
* List blocked IPs
|
|
14
25
|
*
|
|
@@ -29,9 +40,7 @@ export async function adminRoutes(fastify: FastifyInstance): Promise<void> {
|
|
|
29
40
|
* Body: { ip: string }
|
|
30
41
|
*/
|
|
31
42
|
fastify.post('/admin/blocked-ips', {
|
|
32
|
-
schema: {
|
|
33
|
-
body: blockIpSchema,
|
|
34
|
-
},
|
|
43
|
+
schema: { body: blockIpSchema },
|
|
35
44
|
config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
|
|
36
45
|
handler: adminController.blockIpHandler,
|
|
37
46
|
});
|
|
@@ -43,10 +52,107 @@ export async function adminRoutes(fastify: FastifyInstance): Promise<void> {
|
|
|
43
52
|
* Auth: Required (ADMIN)
|
|
44
53
|
*/
|
|
45
54
|
fastify.delete('/admin/blocked-ips/:ip', {
|
|
46
|
-
schema: {
|
|
47
|
-
params: unblockIpParamsSchema,
|
|
48
|
-
},
|
|
55
|
+
schema: { params: unblockIpParamsSchema },
|
|
49
56
|
config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
|
|
50
57
|
handler: adminController.unblockIpHandler,
|
|
51
58
|
});
|
|
59
|
+
|
|
60
|
+
// ─── Dashboard Stats ──────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get dashboard statistics
|
|
64
|
+
*
|
|
65
|
+
* GET /api/v1/admin/stats
|
|
66
|
+
* Auth: Required (ADMIN)
|
|
67
|
+
* Returns: { totalUsers, activeUsers, adminCount, recentSignups, activeSessions }
|
|
68
|
+
*/
|
|
69
|
+
fastify.get('/admin/stats', {
|
|
70
|
+
config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
|
|
71
|
+
handler: adminController.getDashboardStats,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ─── User Management ─────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* List users (paginated, filterable)
|
|
78
|
+
*
|
|
79
|
+
* GET /api/v1/admin/users
|
|
80
|
+
* Auth: Required (ADMIN)
|
|
81
|
+
* Query: ?page=1&limit=10&search=&role=&isActive=&sortBy=createdAt&sortOrder=desc
|
|
82
|
+
*/
|
|
83
|
+
fastify.get('/admin/users', {
|
|
84
|
+
schema: { querystring: getUsersQuerySchema },
|
|
85
|
+
config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
|
|
86
|
+
handler: adminController.getUsers,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get user detail
|
|
91
|
+
*
|
|
92
|
+
* GET /api/v1/admin/users/:userId
|
|
93
|
+
* Auth: Required (ADMIN)
|
|
94
|
+
* Returns: User with sessionCount and lastLogin
|
|
95
|
+
*/
|
|
96
|
+
fastify.get('/admin/users/:userId', {
|
|
97
|
+
schema: { params: userIdParamsSchema },
|
|
98
|
+
config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
|
|
99
|
+
handler: adminController.getUserDetail,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Toggle user active status
|
|
104
|
+
*
|
|
105
|
+
* PATCH /api/v1/admin/users/:userId/status
|
|
106
|
+
* Auth: Required (ADMIN)
|
|
107
|
+
* Body: { isActive: boolean }
|
|
108
|
+
* Side effect: deactivating also invalidates all sessions
|
|
109
|
+
*/
|
|
110
|
+
fastify.patch('/admin/users/:userId/status', {
|
|
111
|
+
schema: { params: userIdParamsSchema, body: updateUserStatusSchema },
|
|
112
|
+
config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
|
|
113
|
+
handler: adminController.updateUserStatus,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Change user role
|
|
118
|
+
*
|
|
119
|
+
* PATCH /api/v1/admin/users/:userId/role
|
|
120
|
+
* Auth: Required (ADMIN)
|
|
121
|
+
* Body: { role: 'USER' | 'ADMIN' }
|
|
122
|
+
* Side effect: demoting ADMIN also invalidates their sessions
|
|
123
|
+
* Protection: cannot change your own role
|
|
124
|
+
*/
|
|
125
|
+
fastify.patch('/admin/users/:userId/role', {
|
|
126
|
+
schema: { params: userIdParamsSchema, body: updateUserRoleSchema },
|
|
127
|
+
config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
|
|
128
|
+
handler: adminController.updateUserRole,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ─── Session Management ───────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* List active sessions (paginated)
|
|
135
|
+
*
|
|
136
|
+
* GET /api/v1/admin/sessions
|
|
137
|
+
* Auth: Required (ADMIN)
|
|
138
|
+
* Query: ?page=1&limit=10&userId=
|
|
139
|
+
*/
|
|
140
|
+
fastify.get('/admin/sessions', {
|
|
141
|
+
schema: { querystring: getSessionsQuerySchema },
|
|
142
|
+
config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
|
|
143
|
+
handler: adminController.getAllSessions,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Force-expire a session
|
|
148
|
+
*
|
|
149
|
+
* DELETE /api/v1/admin/sessions/:sessionId
|
|
150
|
+
* Auth: Required (ADMIN)
|
|
151
|
+
* Side effect: also deletes associated refresh tokens
|
|
152
|
+
*/
|
|
153
|
+
fastify.delete('/admin/sessions/:sessionId', {
|
|
154
|
+
schema: { params: sessionIdParamsSchema },
|
|
155
|
+
config: { rateLimit: RATE_LIMITS.ADMIN_DEFAULT },
|
|
156
|
+
handler: adminController.forceExpireSession,
|
|
157
|
+
});
|
|
52
158
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
+
// ─── IP Blocking ────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
3
5
|
export const blockIpSchema = z.object({
|
|
4
6
|
ip: z.union([z.ipv4(), z.ipv6()], { message: 'Invalid IP address' }),
|
|
5
7
|
reason: z.string().max(500).optional(),
|
|
@@ -11,3 +13,50 @@ export const unblockIpParamsSchema = z.object({
|
|
|
11
13
|
|
|
12
14
|
export type BlockIpInput = z.infer<typeof blockIpSchema>;
|
|
13
15
|
export type UnblockIpParams = z.infer<typeof unblockIpParamsSchema>;
|
|
16
|
+
|
|
17
|
+
// ─── User Management ────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export const getUsersQuerySchema = z.object({
|
|
20
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
21
|
+
limit: z.coerce.number().int().min(1).max(100).default(10),
|
|
22
|
+
search: z.string().max(100).optional(),
|
|
23
|
+
role: z.enum(['USER', 'ADMIN']).optional(),
|
|
24
|
+
isActive: z
|
|
25
|
+
.enum(['true', 'false'])
|
|
26
|
+
.transform((v) => v === 'true')
|
|
27
|
+
.optional(),
|
|
28
|
+
sortBy: z.enum(['createdAt', 'email', 'firstName']).default('createdAt'),
|
|
29
|
+
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const userIdParamsSchema = z.object({
|
|
33
|
+
userId: z.string().uuid('Invalid user ID'),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const updateUserStatusSchema = z.object({
|
|
37
|
+
isActive: z.boolean(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const updateUserRoleSchema = z.object({
|
|
41
|
+
role: z.enum(['USER', 'ADMIN']),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type GetUsersQuery = z.infer<typeof getUsersQuerySchema>;
|
|
45
|
+
export type UserIdParams = z.infer<typeof userIdParamsSchema>;
|
|
46
|
+
export type UpdateUserStatusInput = z.infer<typeof updateUserStatusSchema>;
|
|
47
|
+
export type UpdateUserRoleInput = z.infer<typeof updateUserRoleSchema>;
|
|
48
|
+
|
|
49
|
+
// ─── Session Management ─────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export const getSessionsQuerySchema = z.object({
|
|
52
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
53
|
+
limit: z.coerce.number().int().min(1).max(100).default(10),
|
|
54
|
+
userId: z.string().uuid('Invalid user ID').optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const sessionIdParamsSchema = z.object({
|
|
58
|
+
sessionId: z.string().uuid('Invalid session ID'),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export type GetSessionsQuery = z.infer<typeof getSessionsQuerySchema>;
|
|
62
|
+
export type SessionIdParams = z.infer<typeof sessionIdParamsSchema>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin Service
|
|
3
|
+
*
|
|
4
|
+
* Business logic for admin operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { logger } from '@libs/logger.js';
|
|
8
|
+
import { NotFoundError, ForbiddenError } from '@shared/errors/errors.js';
|
|
9
|
+
import { adminRepository } from './admin.repo.js';
|
|
10
|
+
import { sessionRepository } from '@modules/auth/session.repo.js';
|
|
11
|
+
import { deleteRefreshTokensByUserId } from '@modules/auth/auth.repo.js';
|
|
12
|
+
|
|
13
|
+
import type { UserRole } from '@shared/types/index.js';
|
|
14
|
+
import type { AdminUser, AdminUserDetail, AdminSession, DashboardStats } from './admin.repo.js';
|
|
15
|
+
|
|
16
|
+
class AdminService {
|
|
17
|
+
/**
|
|
18
|
+
* Get paginated list of users with optional filters
|
|
19
|
+
*/
|
|
20
|
+
async getUsers(params: {
|
|
21
|
+
page: number;
|
|
22
|
+
limit: number;
|
|
23
|
+
search?: string;
|
|
24
|
+
role?: UserRole;
|
|
25
|
+
isActive?: boolean;
|
|
26
|
+
sortBy: 'createdAt' | 'email' | 'firstName';
|
|
27
|
+
sortOrder: 'asc' | 'desc';
|
|
28
|
+
}): Promise<{ items: AdminUser[]; totalItems: number }> {
|
|
29
|
+
return adminRepository.getUsers(params);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get detailed user info
|
|
34
|
+
*/
|
|
35
|
+
async getUserDetail(userId: string): Promise<AdminUserDetail> {
|
|
36
|
+
const user = await adminRepository.getUserDetail(userId);
|
|
37
|
+
|
|
38
|
+
if (!user) {
|
|
39
|
+
throw new NotFoundError('User not found');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return user;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Toggle user active status.
|
|
47
|
+
* When deactivating, invalidates all sessions and refresh tokens.
|
|
48
|
+
*/
|
|
49
|
+
async toggleUserStatus(userId: string, isActive: boolean): Promise<AdminUser> {
|
|
50
|
+
const user = await adminRepository.findUserById(userId);
|
|
51
|
+
|
|
52
|
+
if (!user) {
|
|
53
|
+
throw new NotFoundError('User not found');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const updatedUser = await adminRepository.updateUserStatus(userId, isActive);
|
|
57
|
+
|
|
58
|
+
if (!isActive) {
|
|
59
|
+
// Force-logout: invalidate all sessions and refresh tokens
|
|
60
|
+
const deletedSessions = await sessionRepository.deleteAllUserSessions(userId);
|
|
61
|
+
await deleteRefreshTokensByUserId(userId);
|
|
62
|
+
|
|
63
|
+
logger.info({
|
|
64
|
+
msg: 'User deactivated and sessions invalidated',
|
|
65
|
+
userId,
|
|
66
|
+
deletedSessions,
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
logger.info({ msg: 'User activated', userId });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return updatedUser;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Change user role.
|
|
77
|
+
* Prevents self-demotion. On demotion from ADMIN, invalidates sessions.
|
|
78
|
+
*/
|
|
79
|
+
async changeUserRole(
|
|
80
|
+
userId: string,
|
|
81
|
+
role: UserRole,
|
|
82
|
+
adminUserId: string,
|
|
83
|
+
): Promise<AdminUser> {
|
|
84
|
+
if (userId === adminUserId) {
|
|
85
|
+
throw new ForbiddenError('Cannot change your own role');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const user = await adminRepository.findUserById(userId);
|
|
89
|
+
|
|
90
|
+
if (!user) {
|
|
91
|
+
throw new NotFoundError('User not found');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const updatedUser = await adminRepository.updateUserRole(userId, role);
|
|
95
|
+
|
|
96
|
+
// If demoting from ADMIN to USER, invalidate sessions so new role takes effect
|
|
97
|
+
if (user.role === 'ADMIN' && role === 'USER') {
|
|
98
|
+
const deletedSessions = await sessionRepository.deleteAllUserSessions(userId);
|
|
99
|
+
await deleteRefreshTokensByUserId(userId);
|
|
100
|
+
|
|
101
|
+
logger.info({
|
|
102
|
+
msg: 'User demoted from ADMIN and sessions invalidated',
|
|
103
|
+
userId,
|
|
104
|
+
deletedSessions,
|
|
105
|
+
});
|
|
106
|
+
} else {
|
|
107
|
+
logger.info({ msg: 'User role updated', userId, role });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return updatedUser;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get dashboard statistics
|
|
115
|
+
*/
|
|
116
|
+
async getDashboardStats(): Promise<DashboardStats> {
|
|
117
|
+
return adminRepository.getDashboardStats();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get paginated list of active sessions
|
|
122
|
+
*/
|
|
123
|
+
async getAllSessions(params: {
|
|
124
|
+
page: number;
|
|
125
|
+
limit: number;
|
|
126
|
+
userId?: string;
|
|
127
|
+
}): Promise<{ items: AdminSession[]; totalItems: number }> {
|
|
128
|
+
return adminRepository.getAllSessions(params);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Force-expire a specific session.
|
|
133
|
+
* Deletes the session and any associated refresh tokens.
|
|
134
|
+
*/
|
|
135
|
+
async forceExpireSession(sessionId: string): Promise<void> {
|
|
136
|
+
const session = await adminRepository.getSessionById(sessionId);
|
|
137
|
+
|
|
138
|
+
if (!session) {
|
|
139
|
+
throw new NotFoundError('Session not found');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Delete refresh tokens linked to this session first
|
|
143
|
+
await adminRepository.deleteRefreshTokensBySessionId(sessionId);
|
|
144
|
+
await adminRepository.deleteSession(sessionId);
|
|
145
|
+
|
|
146
|
+
logger.info({
|
|
147
|
+
msg: 'Session force-expired by admin',
|
|
148
|
+
sessionId,
|
|
149
|
+
userId: session.userId,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const adminService = new AdminService();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { prisma } from '@libs/prisma.js';
|
|
1
|
+
import { prisma, isPrismaNotFound } from '@libs/prisma.js';
|
|
2
2
|
import type { User, RefreshToken } from '@prisma/client';
|
|
3
3
|
|
|
4
4
|
export async function findUserByEmail(email: string): Promise<User | null> {
|
|
@@ -45,6 +45,7 @@ export async function createUser(data: {
|
|
|
45
45
|
export async function createRefreshToken(data: {
|
|
46
46
|
token: string;
|
|
47
47
|
userId: string;
|
|
48
|
+
sessionId?: string;
|
|
48
49
|
expiresAt: Date;
|
|
49
50
|
}): Promise<RefreshToken> {
|
|
50
51
|
return prisma.refreshToken.create({
|
|
@@ -64,21 +65,14 @@ export async function deleteRefreshToken(token: string): Promise<void> {
|
|
|
64
65
|
where: { token },
|
|
65
66
|
});
|
|
66
67
|
} catch (error) {
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
error instanceof Error &&
|
|
70
|
-
'code' in error &&
|
|
71
|
-
(error as { code: string }).code === 'P2025'
|
|
72
|
-
) {
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
68
|
+
if (isPrismaNotFound(error)) return;
|
|
75
69
|
throw error;
|
|
76
70
|
}
|
|
77
71
|
}
|
|
78
72
|
|
|
79
73
|
export async function rotateRefreshToken(
|
|
80
74
|
oldToken: string,
|
|
81
|
-
newData: { token: string; userId: string; expiresAt: Date },
|
|
75
|
+
newData: { token: string; userId: string; sessionId?: string; expiresAt: Date },
|
|
82
76
|
): Promise<boolean> {
|
|
83
77
|
try {
|
|
84
78
|
await prisma.$transaction([
|
|
@@ -87,14 +81,7 @@ export async function rotateRefreshToken(
|
|
|
87
81
|
]);
|
|
88
82
|
return true;
|
|
89
83
|
} catch (error) {
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
error instanceof Error &&
|
|
93
|
-
'code' in error &&
|
|
94
|
-
(error as { code: string }).code === 'P2025'
|
|
95
|
-
) {
|
|
96
|
-
return false;
|
|
97
|
-
}
|
|
84
|
+
if (isPrismaNotFound(error)) return false;
|
|
98
85
|
throw error;
|
|
99
86
|
}
|
|
100
87
|
}
|