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:
parent
aaf7ec5b59
commit
c7fb2eb357
@ -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);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user