From c7fb2eb3571610956b661e8f7c33775ab0acbb35 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sun, 15 Feb 2026 16:21:26 -0800 Subject: [PATCH] feat(platform-service): add admin user management routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /auth/users — list users (paginated, admin-only) - GET /auth/users/count — total + by-plan counts - GET /auth/users/:id — get user by id - PUT /auth/users/:id — update user (displayName, role, plan, status) - DELETE /auth/users/:id — delete user - repository: added list, count, countByPlan, update, remove functions - types: added UpdateUserSchema --- .../src/modules/auth/repository.ts | 67 +++++++++++++++ .../src/modules/auth/routes.ts | 81 ++++++++++++++++++- .../src/modules/auth/types.ts | 8 ++ 3 files changed, 154 insertions(+), 2 deletions(-) diff --git a/services/platform-service/src/modules/auth/repository.ts b/services/platform-service/src/modules/auth/repository.ts index 863ba7a4..a547e8aa 100644 --- a/services/platform-service/src/modules/auth/repository.ts +++ b/services/platform-service/src/modules/auth/repository.ts @@ -76,6 +76,73 @@ export async function updateLastLogin(id: string): Promise { } } +// ── Admin user management ──────────────────────────────────── + +export async function list(productId: string, limit = 100, offset = 0): Promise { + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit', + parameters: [ + { name: '@productId', value: productId }, + { name: '@offset', value: offset }, + { name: '@limit', value: limit }, + ], + }) + .fetchAll(); + return resources; +} + +export async function count(productId: string): Promise { + const { resources } = await container() + .items.query({ + query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId', + parameters: [{ name: '@productId', value: productId }], + }) + .fetchAll(); + return resources[0] ?? 0; +} + +export async function countByPlan(productId: string): Promise> { + const { resources } = await container() + .items.query<{ plan: string; cnt: number }>({ + query: 'SELECT c.plan, COUNT(1) AS cnt FROM c WHERE c.productId = @productId GROUP BY c.plan', + parameters: [{ name: '@productId', value: productId }], + }) + .fetchAll(); + const result: Record = {}; + for (const r of resources) result[r.plan] = r.cnt; + return result; +} + +export async function update( + id: string, + updates: Partial> +): Promise { + try { + const { resource } = await container().item(id, id).read(); + if (!resource) return null; + const merged = { + ...resource, + ...updates, + updatedAt: new Date().toISOString(), + }; + const { resource: updated } = await container().item(id, id).replace(merged); + return updated ?? null; + } catch { + return null; + } +} + +export async function remove(id: string): Promise { + try { + await container().item(id, id).delete(); + return true; + } catch { + return false; + } +} + export async function hashPassword(password: string): Promise { return bcrypt.hash(password, 10); } diff --git a/services/platform-service/src/modules/auth/routes.ts b/services/platform-service/src/modules/auth/routes.ts index 1be2d78a..43283b10 100644 --- a/services/platform-service/src/modules/auth/routes.ts +++ b/services/platform-service/src/modules/auth/routes.ts @@ -6,16 +6,29 @@ * POST /auth/refresh — refresh access token * GET /auth/me — get current user from token * POST /auth/verify — service-to-service token verification + * + * Admin user management (requires super_admin or admin role): + * GET /auth/users — list users (paginated) + * GET /auth/users/count — total + by-plan counts + * GET /auth/users/:id — get user by id + * PUT /auth/users/:id — update user + * DELETE /auth/users/:id — delete user */ import type { FastifyInstance } from 'fastify'; -import { BadRequestError, UnauthorizedError } from '../../lib/errors.js'; +import { BadRequestError, ForbiddenError, UnauthorizedError } from '../../lib/errors.js'; import { getProduct } from '../products/cache.js'; import * as subscriptionRepo from '../subscriptions/repository.js'; import * as licenseRepo from '../licenses/repository.js'; import * as repo from './repository.js'; import * as jwt from './jwt.js'; -import { LoginSchema, RegisterSchema, RefreshSchema, type UserDoc } from './types.js'; +import { + LoginSchema, + RegisterSchema, + RefreshSchema, + UpdateUserSchema, + type UserDoc, +} from './types.js'; export async function authRoutes(app: FastifyInstance) { // Login @@ -228,4 +241,68 @@ export async function authRoutes(app: FastifyInstance) { return { valid: false, payload: null }; } }); + + // ── Admin user management ──────────────────────────────────── + + function requireAdminRole(req: import('fastify').FastifyRequest): string { + const role = req.jwtPayload?.role; + if (!role || !['super_admin', 'admin'].includes(role)) { + throw new ForbiddenError('Admin access required'); + } + const productId = req.jwtPayload?.productId; + if (!productId) throw new UnauthorizedError('Missing productId in token'); + return productId; + } + + function stripPasswordHash(user: UserDoc) { + const { passwordHash: _h, ...rest } = user; + void _h; + return rest; + } + + // List users (paginated) + app.get('/auth/users', async req => { + const productId = requireAdminRole(req); + const { limit, offset } = req.query as { limit?: string; offset?: string }; + const users = await repo.list(productId, Number(limit) || 100, Number(offset) || 0); + return { users: users.map(stripPasswordHash) }; + }); + + // Count users (total + by plan) + app.get('/auth/users/count', async req => { + const productId = requireAdminRole(req); + const [total, byPlan] = await Promise.all([repo.count(productId), repo.countByPlan(productId)]); + return { total, byPlan }; + }); + + // Get user by ID + app.get('/auth/users/:id', async req => { + requireAdminRole(req); + const { id } = req.params as { id: string }; + const user = await repo.getById(id); + if (!user) throw new BadRequestError('User not found'); + return stripPasswordHash(user); + }); + + // Update user + app.put('/auth/users/:id', async req => { + requireAdminRole(req); + const { id } = req.params as { id: string }; + const parsed = UpdateUserSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const updated = await repo.update(id, parsed.data); + if (!updated) throw new BadRequestError('User not found'); + return stripPasswordHash(updated); + }); + + // Delete user + app.delete('/auth/users/:id', async req => { + requireAdminRole(req); + const { id } = req.params as { id: string }; + const deleted = await repo.remove(id); + if (!deleted) throw new BadRequestError('User not found'); + return { success: true }; + }); } diff --git a/services/platform-service/src/modules/auth/types.ts b/services/platform-service/src/modules/auth/types.ts index 376409ea..0007c8fb 100644 --- a/services/platform-service/src/modules/auth/types.ts +++ b/services/platform-service/src/modules/auth/types.ts @@ -46,5 +46,13 @@ export const RefreshSchema = z.object({ refreshToken: z.string().min(1), }); +export const UpdateUserSchema = z.object({ + displayName: z.string().min(1).optional(), + role: z.enum(['super_admin', 'admin', 'viewer', 'user']).optional(), + plan: z.enum(['free', 'pro', 'enterprise']).optional(), + status: z.enum(['active', 'disabled']).optional(), +}); + export type LoginInput = z.infer; export type RegisterInput = z.infer; +export type UpdateUserInput = z.infer;