feat(platform-service): add admin user management routes

- 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
This commit is contained in:
saravanakumardb1 2026-02-15 16:21:26 -08:00
parent aaf7ec5b59
commit c7fb2eb357
3 changed files with 154 additions and 2 deletions

View File

@ -76,6 +76,73 @@ export async function updateLastLogin(id: string): Promise<void> {
}
}
// ── Admin user management ────────────────────────────────────
export async function list(productId: string, limit = 100, offset = 0): Promise<UserDoc[]> {
const { resources } = await container()
.items.query<UserDoc>({
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<number> {
const { resources } = await container()
.items.query<number>({
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<Record<string, number>> {
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<string, number> = {};
for (const r of resources) result[r.plan] = r.cnt;
return result;
}
export async function update(
id: string,
updates: Partial<Pick<UserDoc, 'displayName' | 'role' | 'plan' | 'status'>>
): Promise<UserDoc | null> {
try {
const { resource } = await container().item(id, id).read<UserDoc>();
if (!resource) return null;
const merged = {
...resource,
...updates,
updatedAt: new Date().toISOString(),
};
const { resource: updated } = await container().item(id, id).replace<UserDoc>(merged);
return updated ?? null;
} catch {
return null;
}
}
export async function remove(id: string): Promise<boolean> {
try {
await container().item(id, id).delete();
return true;
} catch {
return false;
}
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}

View File

@ -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 };
});
}

View File

@ -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<typeof LoginSchema>;
export type RegisterInput = z.infer<typeof RegisterSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;