feat: 添加管理员管理 API

- 新增管理员类型定义 (src/types/admin.ts)
- 新增管理员管理服务 (src/services/admin/admin-management-service.ts)
- 新增管理员管理路由 (src/routes/admin/admins.ts)
- 更新 API 参考文档

功能:
- GET /v1/admin/admins - 获取管理员列表(支持分页和筛选)
- GET /v1/admin/admins/:id - 获取管理员详情
- POST /v1/admin/admins - 创建管理员(super_admin 专属)
- PUT /v1/admin/admins/:id - 更新管理员信息(super_admin 专属)
- DELETE /v1/admin/admins/:id - 软删除管理员(super_admin 专属)
- POST /v1/admin/admins/:id/reset-password - 重置密码(super_admin 专属)

安全特性:
- BCrypt 密码哈希
- 随机密码生成(12 位,包含大小写字母、数字、符号)
- 软删除机制
- 防止删除最后一个 super_admin
- 防止管理员修改自己的关键信息
- 使用 Drizzle ORM ne() 操作符防止 SQL 注入
This commit is contained in:
Wang Zhuoxuan 2026-04-11 18:36:24 +08:00
parent 3991a02a8c
commit f260fd6bfb
5 changed files with 926 additions and 0 deletions

View File

@ -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 | 功能未实现 |

219
src/routes/admin/admins.ts Normal file
View File

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

View File

@ -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<void> {
app.register(adminAuthRoutes);
app.register(adminAdminsRoutes, { prefix: '/admins' });
app.register(adminQuestionsRoutes, { prefix: '/questions' });
app.register(adminCategoriesRoutes, { prefix: '/categories' });
app.register(adminKnowledgeCardsRoutes, { prefix: '/knowledge-cards' });

View File

@ -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<void> {
const [result] = await db
.select({ count: sql<number>`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<boolean> {
const conditions: SQL[] = [eq(adminUsers.username, username)];
if (excludeId) {
conditions.push(ne(adminUsers.id, excludeId));
}
const [existing] = await db
.select({ count: sql<number>`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<number>`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<AdminUserPublic> {
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<CreateAdminResponse> {
// 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<AdminUserPublic> {
// 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<string, unknown> = {};
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<AdminUserPublic> {
// 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<ResetPasswordResponse> {
// 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,
};
}

85
src/types/admin.ts Normal file
View File

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