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:
parent
3991a02a8c
commit
f260fd6bfb
@ -15,6 +15,7 @@
|
|||||||
- [支付](#支付)
|
- [支付](#支付)
|
||||||
- [管理端 API](#管理端-api)
|
- [管理端 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
|
#### GET /admin/questions
|
||||||
@ -1281,6 +1525,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| VALIDATION_ERROR | 请求参数验证失败 |
|
| VALIDATION_ERROR | 请求参数验证失败 |
|
||||||
| UNAUTHORIZED | 未认证或认证失败 |
|
| UNAUTHORIZED | 未认证或认证失败 |
|
||||||
|
| FORBIDDEN | 权限不足(需要 super_admin) |
|
||||||
| NOT_FOUND | 资源不存在 |
|
| NOT_FOUND | 资源不存在 |
|
||||||
| INVALID_RECEIPT | 支付收据验证失败 |
|
| INVALID_RECEIPT | 支付收据验证失败 |
|
||||||
| NOT_IMPLEMENTED | 功能未实现 |
|
| NOT_IMPLEMENTED | 功能未实现 |
|
||||||
|
|||||||
219
src/routes/admin/admins.ts
Normal file
219
src/routes/admin/admins.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { adminAuthRoutes } from './auth.js';
|
import { adminAuthRoutes } from './auth.js';
|
||||||
|
import { adminAdminsRoutes } from './admins.js';
|
||||||
import { adminQuestionsRoutes } from './questions.js';
|
import { adminQuestionsRoutes } from './questions.js';
|
||||||
import { adminCategoriesRoutes } from './categories.js';
|
import { adminCategoriesRoutes } from './categories.js';
|
||||||
import { adminKnowledgeCardsRoutes } from './knowledge-cards.js';
|
import { adminKnowledgeCardsRoutes } from './knowledge-cards.js';
|
||||||
@ -10,6 +11,7 @@ import { adminFeedbackRoutes } from './feedback.js';
|
|||||||
|
|
||||||
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.register(adminAuthRoutes);
|
app.register(adminAuthRoutes);
|
||||||
|
app.register(adminAdminsRoutes, { prefix: '/admins' });
|
||||||
app.register(adminQuestionsRoutes, { prefix: '/questions' });
|
app.register(adminQuestionsRoutes, { prefix: '/questions' });
|
||||||
app.register(adminCategoriesRoutes, { prefix: '/categories' });
|
app.register(adminCategoriesRoutes, { prefix: '/categories' });
|
||||||
app.register(adminKnowledgeCardsRoutes, { prefix: '/knowledge-cards' });
|
app.register(adminKnowledgeCardsRoutes, { prefix: '/knowledge-cards' });
|
||||||
|
|||||||
375
src/services/admin/admin-management-service.ts
Normal file
375
src/services/admin/admin-management-service.ts
Normal 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
85
src/types/admin.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user