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> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
return bcrypt.hash(password, 10);
|
return bcrypt.hash(password, 10);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,16 +6,29 @@
|
|||||||
* POST /auth/refresh — refresh access token
|
* POST /auth/refresh — refresh access token
|
||||||
* GET /auth/me — get current user from token
|
* GET /auth/me — get current user from token
|
||||||
* POST /auth/verify — service-to-service token verification
|
* 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 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 { getProduct } from '../products/cache.js';
|
||||||
import * as subscriptionRepo from '../subscriptions/repository.js';
|
import * as subscriptionRepo from '../subscriptions/repository.js';
|
||||||
import * as licenseRepo from '../licenses/repository.js';
|
import * as licenseRepo from '../licenses/repository.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import * as jwt from './jwt.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) {
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
// Login
|
// Login
|
||||||
@ -228,4 +241,68 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
return { valid: false, payload: null };
|
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),
|
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 LoginInput = z.infer<typeof LoginSchema>;
|
||||||
export type RegisterInput = z.infer<typeof RegisterSchema>;
|
export type RegisterInput = z.infer<typeof RegisterSchema>;
|
||||||
|
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user