diff --git a/docs/api-reference.md b/docs/api-reference.md index 7b84381..c032599 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -15,6 +15,7 @@ - [支付](#支付) - [管理端 API](#管理端-api) - [管理端认证](#管理端认证) + - [管理员管理](#管理员管理) - [题目管理](#题目管理) - [分类管理](#分类管理) - [知识点卡片](#知识点卡片) @@ -792,6 +793,249 @@ --- +### 管理员管理 + +> 仅 super_admin 角色可执行写操作(POST、PUT、DELETE),读操作(GET)所有管理员均可访问。 + +#### GET /admin/admins + +获取管理员列表。 + +**认证**: Admin JWT + +**查询参数**: +- `page`: 页码 (默认: 1, 必须 ≥ 1) +- `limit`: 每页数量 (默认: 20, 范围: 1-50) +- `role`: "admin" | "super_admin" (可选,按角色筛选) +- `isActive`: 0 | 1 (可选,按状态筛选) + +**响应**: +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "username": "admin", + "role": "super_admin", + "isActive": 1, + "lastLoginAt": "2026-04-11T10:00:00.000Z", + "createdAt": "2026-04-01T00:00:00.000Z", + "updatedAt": "2026-04-11T10:00:00.000Z" + } + ], + "pagination": { + "total": 5, + "page": 1, + "limit": 20 + }, + "error": null +} +``` + +--- + +#### GET /admin/admins/:id + +获取管理员详情。 + +**认证**: Admin JWT + +**路径参数**: +- `id`: 管理员 ID + +**响应**: +```json +{ + "success": true, + "data": { + "id": "uuid", + "username": "admin", + "role": "super_admin", + "isActive": 1, + "lastLoginAt": "2026-04-11T10:00:00.000Z", + "createdAt": "2026-04-01T00:00:00.000Z", + "updatedAt": "2026-04-11T10:00:00.000Z" + }, + "error": null +} +``` + +**错误 (404)**: +```json +{ + "success": false, + "data": null, + "error": { + "code": "NOT_FOUND", + "message": "Admin user not found" + } +} +``` + +--- + +#### POST /admin/admins + +创建新管理员(super_admin 专属)。 + +**认证**: Admin JWT (super_admin) + +**请求体**: +```json +{ + "username": "string (必填, 3-50字符)", + "password": "string (必填, 8-128字符)", + "role": "admin | super_admin (必填)" +} +``` + +**响应**: +```json +{ + "success": true, + "data": { + "id": "uuid", + "username": "newadmin", + "role": "admin", + "isActive": 1, + "lastLoginAt": null, + "createdAt": "2026-04-11T12:00:00.000Z", + "updatedAt": "2026-04-11T12:00:00.000Z", + "plainPassword": "随机生成的初始密码" + }, + "error": null +} +``` + +**错误 (403)**: +```json +{ + "success": false, + "data": null, + "error": { + "code": "FORBIDDEN", + "message": "Super admin privileges required" + } +} +``` + +**错误 (400)**: +```json +{ + "success": false, + "data": null, + "error": { + "code": "VALIDATION_ERROR", + "message": "Username \"admin\" already exists" + } +} +``` + +--- + +#### PUT /admin/admins/:id + +更新管理员信息(super_admin 专属)。 + +**认证**: Admin JWT (super_admin) + +**路径参数**: +- `id`: 管理员 ID + +**请求体**: +```json +{ + "username": "string (可选, 3-50字符)", + "role": "admin | super_admin (可选)", + "isActive": 0 | 1 (可选)" +} +``` + +**响应**: +```json +{ + "success": true, + "data": { + "id": "uuid", + "username": "updated_username", + "role": "admin", + "isActive": 0, + "lastLoginAt": "2026-04-11T10:00:00.000Z", + "createdAt": "2026-04-01T00:00:00.000Z", + "updatedAt": "2026-04-11T12:00:00.000Z" + }, + "error": null +} +``` + +**安全规则**: +- 禁止删除或降级最后一个 super_admin +- 用户名必须唯一 + +--- + +#### DELETE /admin/admins/:id + +软删除管理员(super_admin 专属)。 + +**认证**: Admin JWT (super_admin) + +**路径参数**: +- `id`: 管理员 ID + +**响应**: +```json +{ + "success": true, + "data": { + "id": "uuid", + "username": "deleted_admin", + "role": "admin", + "isActive": 0, + "lastLoginAt": "2026-04-11T10:00:00.000Z", + "createdAt": "2026-04-01T00:00:00.000Z", + "updatedAt": "2026-04-11T12:00:00.000Z" + }, + "error": null +} +``` + +**说明**: +- 软删除:将 `isActive` 设为 0,不删除记录 +- 禁止删除最后一个 super_admin + +--- + +#### POST /admin/admins/:id/reset-password + +重置管理员密码(super_admin 专属)。 + +**认证**: Admin JWT (super_admin) + +**路径参数**: +- `id`: 管理员 ID + +**响应**: +```json +{ + "success": true, + "data": { + "adminId": "uuid", + "username": "admin", + "plainPassword": "新随机生成的12位密码" + }, + "error": null +} +``` + +**说明**: +- 生成 12 位随机密码,包含大小写字母、数字、符号 +- 明文密码仅在响应中返回一次,请妥善保存 +- 密码使用 BCrypt 哈希后存储到数据库 + +--- + ### 题目管理 #### GET /admin/questions @@ -1281,6 +1525,7 @@ |------|------| | VALIDATION_ERROR | 请求参数验证失败 | | UNAUTHORIZED | 未认证或认证失败 | +| FORBIDDEN | 权限不足(需要 super_admin) | | NOT_FOUND | 资源不存在 | | INVALID_RECEIPT | 支付收据验证失败 | | NOT_IMPLEMENTED | 功能未实现 | diff --git a/src/routes/admin/admins.ts b/src/routes/admin/admins.ts new file mode 100644 index 0000000..452abbf --- /dev/null +++ b/src/routes/admin/admins.ts @@ -0,0 +1,219 @@ +/** + * Admin User Management Routes + * + * Provides CRUD endpoints for managing admin user accounts. + * Read operations (GET) are accessible to all admins. + * Write operations (POST, PUT, DELETE) require super_admin role. + * + * Routes: + * - GET /v1/admin/admins - List admins (paginated, filtered) + * - GET /v1/admin/admins/:id - Get admin by ID + * - POST /v1/admin/admins - Create new admin (super_admin only) + * - PUT /v1/admin/admins/:id - Update admin (super_admin only) + * - DELETE /v1/admin/admins/:id - Soft delete admin (super_admin only) + * - POST /v1/admin/admins/:id/reset-password - Reset password (super_admin only) + */ + +import { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { JwtPayload } from '../../types/auth.js'; +import { ForbiddenError } from '../../utils/errors.js'; +import * as adminManagementService from '../../services/admin/admin-management-service.js'; + +// ── Zod Schemas ─────────────────────────────────────────────────────── + +const listAdminsQuerySchema = z.object({ + page: z.coerce.number().int().positive().optional().default(1), + limit: z.coerce.number().int().positive().max(50).optional().default(20), + role: z.enum(['admin', 'super_admin']).optional(), + isActive: z.coerce.number().int().min(0).max(1).optional(), +}); + +const createAdminSchema = z.object({ + username: z.string().min(3, 'Username must be at least 3 characters').max(50, 'Username must be at most 50 characters'), + password: z.string().min(8, 'Password must be at least 8 characters').max(128, 'Password must be at most 128 characters'), + role: z.enum(['admin', 'super_admin']), +}); + +const updateAdminSchema = z.object({ + username: z.string().min(3, 'Username must be at least 3 characters').max(50, 'Username must be at most 50 characters').optional(), + role: z.enum(['admin', 'super_admin']).optional(), + isActive: z.number().int().min(0, 'isActive must be 0 or 1').max(1, 'isActive must be 0 or 1').optional(), +}); + +// ── Helper Functions ─────────────────────────────────────────────────── + +/** + * Require super_admin role for the current request. + * Throws ForbiddenError if the user is not a super_admin. + * + * @param request - Fastify request with authenticated user + * @param targetAdminId - Optional admin ID to check against (prevents self-modification) + * @throws {ForbiddenError} If user is not a super_admin or trying to modify themselves + */ +function requireSuperAdmin(request: { user?: unknown }, targetAdminId?: string): void { + const user = request.user as JwtPayload | undefined; + if (user?.role !== 'super_admin') { + throw new ForbiddenError('Super admin privileges required'); + } + + // Prevent self-modification for critical operations + if (targetAdminId && user.userId === targetAdminId) { + throw new ForbiddenError('Cannot modify your own account'); + } +} + +// ── Routes ───────────────────────────────────────────────────────────── + +export async function adminAdminsRoutes(app: FastifyInstance): Promise { + // GET / - List all admins (accessible to all admins) + app.get('/', async (request) => { + const parsed = listAdminsQuerySchema.safeParse(request.query); + if (!parsed.success) { + return { + success: false, + data: null, + error: { + code: 'VALIDATION_ERROR', + message: parsed.error.issues[0]?.message ?? 'Invalid query parameters', + }, + }; + } + + const result = await adminManagementService.listAdmins(parsed.data); + return { + success: true, + data: result.items, + pagination: result.pagination, + error: null, + }; + }); + + // GET /:id - Get admin by ID (accessible to all admins) + app.get('/:id', async (request) => { + const { id } = request.params as { id: string }; + + try { + const data = await adminManagementService.getAdminById(id); + return { success: true, data, error: null }; + } catch (error) { + if (error instanceof Error) { + return { + success: false, + data: null, + error: { code: 'NOT_FOUND', message: error.message }, + }; + } + throw error; + } + }); + + // POST / - Create new admin (super_admin only) + app.post('/', async (request) => { + // Permission check + requireSuperAdmin(request); + + const parsed = createAdminSchema.safeParse(request.body); + if (!parsed.success) { + return { + success: false, + data: null, + error: { + code: 'VALIDATION_ERROR', + message: parsed.error.issues[0]?.message ?? 'Invalid request body', + }, + }; + } + + try { + const data = await adminManagementService.createAdmin(parsed.data); + return { success: true, data, error: null }; + } catch (error) { + if (error instanceof Error) { + return { + success: false, + data: null, + error: { code: 'VALIDATION_ERROR', message: error.message }, + }; + } + throw error; + } + }); + + // PUT /:id - Update admin (super_admin only) + app.put('/:id', async (request) => { + const { id } = request.params as { id: string }; + // Permission check (prevent self-modification) + requireSuperAdmin(request, id); + + const parsed = updateAdminSchema.safeParse(request.body); + if (!parsed.success) { + return { + success: false, + data: null, + error: { + code: 'VALIDATION_ERROR', + message: parsed.error.issues[0]?.message ?? 'Invalid request body', + }, + }; + } + + try { + const data = await adminManagementService.updateAdmin(id, parsed.data); + return { success: true, data, error: null }; + } catch (error) { + if (error instanceof Error) { + const code = error.message.includes('not found') ? 'NOT_FOUND' : 'VALIDATION_ERROR'; + return { + success: false, + data: null, + error: { code, message: error.message }, + }; + } + throw error; + } + }); + + // DELETE /:id - Soft delete admin (super_admin only) + app.delete('/:id', async (request) => { + const { id } = request.params as { id: string }; + // Permission check (prevent self-modification) + requireSuperAdmin(request, id); + + try { + const data = await adminManagementService.deleteAdmin(id); + return { success: true, data, error: null }; + } catch (error) { + if (error instanceof Error) { + return { + success: false, + data: null, + error: { code: 'NOT_FOUND', message: error.message }, + }; + } + throw error; + } + }); + + // POST /:id/reset-password - Reset admin password (super_admin only) + app.post('/:id/reset-password', async (request) => { + // Permission check (allow resetting own password) + requireSuperAdmin(request); + + const { id } = request.params as { id: string }; + + try { + const data = await adminManagementService.resetPassword(id); + return { success: true, data, error: null }; + } catch (error) { + if (error instanceof Error) { + return { + success: false, + data: null, + error: { code: 'NOT_FOUND', message: error.message }, + }; + } + throw error; + } + }); +} diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 4ac2f65..76bc38a 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from 'fastify'; import { adminAuthRoutes } from './auth.js'; +import { adminAdminsRoutes } from './admins.js'; import { adminQuestionsRoutes } from './questions.js'; import { adminCategoriesRoutes } from './categories.js'; import { adminKnowledgeCardsRoutes } from './knowledge-cards.js'; @@ -10,6 +11,7 @@ import { adminFeedbackRoutes } from './feedback.js'; export async function adminRoutes(app: FastifyInstance): Promise { app.register(adminAuthRoutes); + app.register(adminAdminsRoutes, { prefix: '/admins' }); app.register(adminQuestionsRoutes, { prefix: '/questions' }); app.register(adminCategoriesRoutes, { prefix: '/categories' }); app.register(adminKnowledgeCardsRoutes, { prefix: '/knowledge-cards' }); diff --git a/src/services/admin/admin-management-service.ts b/src/services/admin/admin-management-service.ts new file mode 100644 index 0000000..9b0d811 --- /dev/null +++ b/src/services/admin/admin-management-service.ts @@ -0,0 +1,375 @@ +/** + * Admin User Management Service + * + * Handles CRUD operations for admin user accounts. + * All write operations require super_admin role. + * + * Security notes: + * - Passwords are hashed with bcrypt before storage + * - Soft delete is used (isActive = 0) + * - At least one super_admin must always remain active + * - Plain passwords are only returned once during creation/reset + */ + +import { db } from '../../db/client.js'; +import { adminUsers } from '../../db/schema.js'; +import { eq, and, ne, sql, type SQL } from 'drizzle-orm'; +import { v4 as uuid } from 'uuid'; +import * as crypto from 'node:crypto'; +import { NotFoundError, ValidationError, ForbiddenError } from '../../utils/errors.js'; +import { hashPassword } from './admin-auth.js'; +import type { + AdminUserPublic, + AdminUserRow, + ListAdminsQuery, + CreateAdminRequest, + UpdateAdminRequest, + CreateAdminResponse, + ResetPasswordResponse, +} from '../../types/admin.js'; +import type { PaginationMeta } from '../../types/api.js'; + +// ── Helper Functions ───────────────────────────────────────────────────── + +/** + * Convert database row to public view (strips passwordHash). + * Pure function - returns a new object. + */ +function toPublicView(row: AdminUserRow): AdminUserPublic { + return { + id: row.id, + username: row.username, + role: row.role ?? 'admin', + isActive: row.isActive ?? 1, + lastLoginAt: row.lastLoginAt ?? null, + createdAt: row.createdAt ?? null, + updatedAt: row.updatedAt ?? null, + }; +} + +/** + * Generate a cryptographically secure random password. + * + * @param length - Password length (default: 12, min: 8, max: 32) + * @returns A random password containing uppercase, lowercase, digits, and symbols + * + * Guarantees at least one character from each category: + * - Uppercase: A-Z + * - Lowercase: a-z + * - Digits: 0-9 + * - Symbols: !@#$%^&*()_+-=[]{}|;:,.<>? + */ +function generateRandomPassword(length: number = 12): string { + const safeLength = Math.max(8, Math.min(32, length)); + + const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const LOWER = 'abcdefghijklmnopqrstuvwxyz'; + const DIGITS = '0123456789'; + const SYMBOLS = '!@#$%^&*()_+-=[]{}|;:,.<>?'; + const ALL = UPPER + LOWER + DIGITS + SYMBOLS; + + // Ensure at least one character from each category + const password: string[] = []; + password.push(UPPER.at(crypto.randomInt(UPPER.length))!); + password.push(LOWER.at(crypto.randomInt(LOWER.length))!); + password.push(DIGITS.at(crypto.randomInt(DIGITS.length))!); + password.push(SYMBOLS.at(crypto.randomInt(SYMBOLS.length))!); + + // Fill the rest with random characters from all categories + for (let i = password.length; i < safeLength; i++) { + password.push(ALL.at(crypto.randomInt(ALL.length))!); + } + + // Fisher-Yates shuffle for uniform distribution + for (let i = password.length - 1; i > 0; i--) { + const j = crypto.randomInt(i + 1); + const temp: string = password[i]!; + password[i] = password[j]!; + password[j] = temp; + } + + return password.join(''); +} + +/** + * Ensure at least one active super_admin would remain + * after deactivating or changing the role of the given admin. + * + * @throws {ForbiddenError} If this operation would leave no active super_admin + */ +async function ensureSuperAdminRemains(excludeAdminId: string): Promise { + const [result] = await db + .select({ count: sql`COUNT(*)` }) + .from(adminUsers) + .where( + and( + eq(adminUsers.role, 'super_admin'), + eq(adminUsers.isActive, 1), + ne(adminUsers.id, excludeAdminId), + ), + ); + + const remainingCount = Number(result?.count ?? 0); + if (remainingCount === 0) { + throw new ForbiddenError('Cannot deactivate or demote the last super_admin'); + } +} + +/** + * Check if a username already exists (excluding a specific admin for updates). + */ +async function isUsernameTaken(username: string, excludeId?: string): Promise { + const conditions: SQL[] = [eq(adminUsers.username, username)]; + if (excludeId) { + conditions.push(ne(adminUsers.id, excludeId)); + } + + const [existing] = await db + .select({ count: sql`COUNT(*)` }) + .from(adminUsers) + .where(and(...conditions)); + + return (Number(existing?.count ?? 0)) > 0; +} + +// ── Public Service Functions ──────────────────────────────────────────── + +/** + * List admin users with pagination and optional filtering. + * + * @param query - Pagination and filter parameters + * @returns Paginated list of admin users (public view) + */ +export async function listAdmins( + query: ListAdminsQuery, +): Promise<{ items: readonly AdminUserPublic[]; pagination: PaginationMeta }> { + const { page = 1, limit = 20, role, isActive } = query; + const offset = (page - 1) * limit; + + // Build filter conditions + const conditions: SQL[] = []; + if (role) { + conditions.push(eq(adminUsers.role, role)); + } + if (isActive !== undefined) { + conditions.push(eq(adminUsers.isActive, isActive)); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + // Get total count + const [countResult] = await db + .select({ total: sql`COUNT(*)` }) + .from(adminUsers) + .where(whereClause); + + const total = Number(countResult?.total ?? 0); + + // Get paginated items + const items = await db + .select() + .from(adminUsers) + .where(whereClause) + .orderBy(adminUsers.createdAt) + .limit(limit) + .offset(offset); + + return { + items: items.map(toPublicView), + pagination: { total, page, limit }, + }; +} + +/** + * Get a single admin user by ID. + * + * @param id - Admin user ID + * @returns Admin user in public view + * @throws {NotFoundError} If admin not found + */ +export async function getAdminById(id: string): Promise { + const [admin] = await db + .select() + .from(adminUsers) + .where(eq(adminUsers.id, id)) + .limit(1); + + if (!admin) { + throw new NotFoundError('Admin user'); + } + + return toPublicView(admin); +} + +/** + * Create a new admin user. + * + * @param data - Admin creation data (username, password, role) + * @returns Created admin with plaintext password (one-time view) + * @throws {ValidationError} If username already exists + */ +export async function createAdmin(data: CreateAdminRequest): Promise { + // Check username uniqueness + if (await isUsernameTaken(data.username)) { + throw new ValidationError(`Username "${data.username}" already exists`); + } + + // Hash password + const passwordHash = await hashPassword(data.password); + const id = uuid(); + + // Insert admin user + await db.insert(adminUsers).values({ + id, + username: data.username, + passwordHash, + role: data.role, + isActive: 1, + }); + + // Retrieve created admin + const [created] = await db + .select() + .from(adminUsers) + .where(eq(adminUsers.id, id)) + .limit(1); + + if (!created) { + throw new Error('Failed to retrieve created admin'); + } + + return { + ...toPublicView(created), + plainPassword: data.password, + }; +} + +/** + * Update admin user fields (username, role, isActive). + * + * @param id - Admin user ID + * @param data - Fields to update (all optional) + * @returns Updated admin in public view + * @throws {NotFoundError} If admin not found + * @throws {ValidationError} If new username conflicts + * @throws {ForbiddenError} If operation would leave no active super_admin + */ +export async function updateAdmin( + id: string, + data: UpdateAdminRequest, +): Promise { + // Get existing admin + const [existing] = await db + .select() + .from(adminUsers) + .where(eq(adminUsers.id, id)) + .limit(1); + + if (!existing) { + throw new NotFoundError('Admin user'); + } + + // Check username uniqueness if changing username + if (data.username && data.username !== existing.username) { + if (await isUsernameTaken(data.username, id)) { + throw new ValidationError(`Username "${data.username}" already exists`); + } + } + + // Build update object (only include provided fields) + const updates: Record = {}; + if (data.username !== undefined) updates.username = data.username; + if (data.role !== undefined) updates.role = data.role; + if (data.isActive !== undefined) updates.isActive = data.isActive; + + // Safety check: don't allow deactivating or demoting the last super_admin + if (data.role === 'admin' || data.isActive === 0) { + if (existing.role === 'super_admin' && (existing.isActive ?? 1) === 1) { + await ensureSuperAdminRemains(id); + } + } + + // Apply updates if any + if (Object.keys(updates).length > 0) { + await db.update(adminUsers).set(updates).where(eq(adminUsers.id, id)); + } + + // Retrieve updated admin + const [updated] = await db + .select() + .from(adminUsers) + .where(eq(adminUsers.id, id)) + .limit(1); + + return toPublicView(updated!); +} + +/** + * Soft-delete an admin by setting isActive = 0. + * + * @param id - Admin user ID + * @returns Deactivated admin in public view + * @throws {NotFoundError} If admin not found + * @throws {ForbiddenError} If deactivating the last super_admin + */ +export async function deleteAdmin(id: string): Promise { + // Get existing admin + const [existing] = await db + .select() + .from(adminUsers) + .where(eq(adminUsers.id, id)) + .limit(1); + + if (!existing) { + throw new NotFoundError('Admin user'); + } + + // Safety check: don't allow deactivating the last super_admin + if (existing.role === 'super_admin' && (existing.isActive ?? 1) === 1) { + await ensureSuperAdminRemains(id); + } + + // Soft delete + await db + .update(adminUsers) + .set({ isActive: 0 }) + .where(eq(adminUsers.id, id)); + + return toPublicView({ ...existing, isActive: 0 }); +} + +/** + * Reset an admin's password to a new random value. + * + * @param id - Admin user ID + * @returns Admin ID, username, and new plaintext password (one-time view) + * @throws {NotFoundError} If admin not found + */ +export async function resetPassword(id: string): Promise { + // Get existing admin + const [existing] = await db + .select() + .from(adminUsers) + .where(eq(adminUsers.id, id)) + .limit(1); + + if (!existing) { + throw new NotFoundError('Admin user'); + } + + // Generate new random password + const plainPassword = generateRandomPassword(12); + const passwordHash = await hashPassword(plainPassword); + + // Update password + await db + .update(adminUsers) + .set({ passwordHash }) + .where(eq(adminUsers.id, id)); + + return { + adminId: existing.id, + username: existing.username, + plainPassword, + }; +} diff --git a/src/types/admin.ts b/src/types/admin.ts new file mode 100644 index 0000000..1624f5e --- /dev/null +++ b/src/types/admin.ts @@ -0,0 +1,85 @@ +/** + * Admin user management types + * + * This file defines all types related to admin user CRUD operations, + * including requests, responses, and public-facing data structures. + * + * Important: passwordHash is NEVER exposed in public-facing types. + */ + +import type { adminUsers } from '../db/schema.js'; + +// ── Database row types (inferred from schema) ──────────────────────── + +export type AdminUserRow = typeof adminUsers.$inferSelect; +export type AdminUserInsert = typeof adminUsers.$inferInsert; + +// ── Public-facing admin view (never expose passwordHash) ───────────── + +/** + * Safe public representation of an admin user. + * This is what gets returned in API responses. + */ +export interface AdminUserPublic { + readonly id: string; + readonly username: string; + readonly role: 'admin' | 'super_admin'; + readonly isActive: number; + readonly lastLoginAt: Date | null; + readonly createdAt: Date | null; + readonly updatedAt: Date | null; +} + +// ── Request types ───────────────────────────────────────────────────── + +/** + * Query parameters for listing admin users. + * Supports pagination and filtering by role/status. + */ +export interface ListAdminsQuery { + readonly page?: number; + readonly limit?: number; + readonly role?: 'admin' | 'super_admin'; + readonly isActive?: number; // 0 or 1 +} + +/** + * Request body for creating a new admin user. + * Only super_admin can create new admins. + */ +export interface CreateAdminRequest { + readonly username: string; + readonly password: string; + readonly role: 'admin' | 'super_admin'; +} + +/** + * Request body for updating an admin user. + * All fields are optional - only provided fields are updated. + */ +export interface UpdateAdminRequest { + readonly username?: string; + readonly role?: 'admin' | 'super_admin'; + readonly isActive?: number; // 0 or 1 +} + +// ── Response types ─────────────────────────────────────────────────── + +/** + * Response when creating an admin user. + * Includes the plaintext password so the creator can share it securely. + * This is the ONLY time the plaintext password is returned. + */ +export interface CreateAdminResponse extends AdminUserPublic { + readonly plainPassword: string; +} + +/** + * Response when resetting an admin's password. + * Includes the new randomly-generated password. + */ +export interface ResetPasswordResponse { + readonly adminId: string; + readonly username: string; + readonly plainPassword: string; +}