From 312a5ccb8612a167365bc51c76725eaec584c5bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E7=85=9C?= <1871263099@qq.com> Date: Mon, 5 Oct 2026 14:08:39 +0800 Subject: [PATCH] feat: add admin user management and disable public registration --- README.md | 9 +- apps/api/.env.example | 7 +- apps/api/package.json | 1 + .../migration.sql | 4 + apps/api/prisma/schema.prisma | 6 + apps/api/scripts/test-isolated.cjs | 128 +++++++++ apps/api/src/admin.ts | 123 ++++++++ apps/api/src/auth.ts | 91 +++++- apps/api/src/main.ts | 3 + apps/api/src/mcp/oauth.ts | 26 ++ apps/api/src/mcp/operations.ts | 4 + apps/api/src/openapi.ts | 6 +- apps/api/src/rates.ts | 3 + apps/api/src/user-access.ts | 16 ++ apps/api/src/validation.ts | 6 +- apps/api/test/admin-integration.test.ts | 235 +++++++++++++++ apps/api/test/codex-oauth.test.ts | 4 +- apps/api/test/icons.test.ts | 9 +- apps/api/test/integration.test.ts | 4 +- apps/api/test/mcp.test.ts | 10 +- apps/api/test/privacy.test.ts | 4 +- apps/api/test/transfers.test.ts | 4 +- apps/api/test/update-integration.test.ts | 4 +- apps/api/test/user-fixture.ts | 14 + apps/web/src/AdminPanel.tsx | 270 ++++++++++++++++++ apps/web/src/App.tsx | 68 ++--- apps/web/src/api.ts | 3 + apps/web/src/locales/en.json | 36 ++- apps/web/src/locales/zh-Hant.json | 42 ++- apps/web/src/style.css | 25 ++ docs/admin-accounts.md | 22 ++ docs/architecture.md | 2 +- 32 files changed, 1120 insertions(+), 69 deletions(-) create mode 100644 apps/api/prisma/migrations/20261005130000_admin_accounts/migration.sql create mode 100644 apps/api/scripts/test-isolated.cjs create mode 100644 apps/api/src/admin.ts create mode 100644 apps/api/src/user-access.ts create mode 100644 apps/api/test/admin-integration.test.ts create mode 100644 apps/api/test/user-fixture.ts create mode 100644 apps/web/src/AdminPanel.tsx create mode 100644 docs/admin-accounts.md diff --git a/README.md b/README.md index 965e7cc..07f3db3 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,16 @@ pnpm typecheck pnpm build pnpm test pnpm test:integration # 需先启动 API;只创建并清理随机命名的临时测试用户 +pnpm --filter @worthpath/api test:isolated # 临时 MySQL 库与独立 API,包含管理员及 MCP 测试;需创建/删除测试库权限 pnpm db:status pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的文件 ``` -当前功能:注册登录、资产与负债账户、独立资产及债务、账户分组、快速记账与撤回、双边还款及转账、月份还款和当天记账标记、单项统计开关、余额历史管理、收支日历、按需执行的定时计划、隐藏资产、净资产趋势、多币种换算、贵金属按克估价、ZIP v9 备份恢复,以及 OAuth/MCP 助手接入。 +当前功能:登录、首次改密与管理员账号管理、资产与负债账户、独立资产及债务、账户分组、快速记账与撤回、双边还款及转账、月份还款和当天记账标记、单项统计开关、余额历史管理、收支日历、按需执行的定时计划、隐藏资产、净资产趋势、多币种换算、贵金属按克估价、ZIP v9 备份恢复,以及 OAuth/MCP 助手接入。 + +公开注册已关闭,账号由管理员后台创建。`apps/api/.env` 可配置 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD`,未配置时均为 `admin`。仅在数据库没有管理员时创建初始管理员;不会提升同名已有用户,也不会在重启时覆盖管理员密码。同名已有用户冲突时请配置其他管理员用户名。首次登录必须修改初始密码,新密码至少 10 个字符、最多 72 字节。 + +管理员登录并改密后,侧栏显示“管理员后台”,可添加账号、设置管理员/普通用户/只读用户权限、封禁和解除封禁。新建账号也需要首次改密。现有账号保留普通用户权限;管理员账号管理不会授予查看其他用户财务数据的能力。权限或封禁状态变化会撤销该用户全部登录会话和 MCP 授权;只读账号的业务写入、MCP 写入和草稿均由服务端拒绝,仍可改自己的登录密码、授权只读连接。详细验证见 [管理员功能验证](docs/admin-accounts.md)。 金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额;已有错误记录可单独更正,转账及其之前的历史需通过新增余额调整修正,避免破坏双方一致性。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。 @@ -75,7 +80,7 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的 Invoke-RestMethod http://localhost:5173/api/openapi.json ``` -接口定义由实际控制器路由和表单校验模式生成。文档本身不包含用户财务数据;除注册、登录及健康检查外,业务接口需登录会话 Cookie。建议先在同一站点登录,再通过前端代理地址使用文档;写入接口仍受来源校验和数据隔离约束。部署时将上述地址中的主机替换为自己的站点,并将 `/api` 代理到后端。实现使用 [NestJS Swagger](https://docs.nestjs.com/openapi/introduction)。 +接口定义由实际控制器路由和表单校验模式生成。文档本身不包含用户财务数据;除登录及健康检查外,业务接口需登录会话 Cookie。建议先在同一站点登录,再通过前端代理地址使用文档;写入接口仍受来源校验和数据隔离约束。部署时将上述地址中的主机替换为自己的站点,并将 `/api` 代理到后端。实现使用 [NestJS Swagger](https://docs.nestjs.com/openapi/introduction)。 ## 账户分组、定时计划与收支日历 diff --git a/apps/api/.env.example b/apps/api/.env.example index df5d1e7..9782c86 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -6,6 +6,11 @@ # 未明确设置的网络开关会按环境采用默认值;本示例明确设置的开关优先。 NODE_ENV=development +# 仅在数据库没有管理员时初始化;不覆盖同名已有用户、不重置已有管理员密码。 +# 初次登录必须更换密码;新密码至少 10 个字符、最多 72 字节。 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin + # MySQL 连接字符串:替换用户名、密码、主机、端口及数据库名。 # 密码中的特殊字符需要进行 URL 编码。 DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/worthpath" @@ -80,7 +85,7 @@ COOKIE_SAME_SITE=strict NETWORK_RATE_LIMIT_ENABLED=true # 同一来源累计请求的时间窗口,单位毫秒;900000 为 15 分钟。 NETWORK_RATE_LIMIT_WINDOW_MS=900000 -# 每个来源在窗口内可尝试的登录、注册或安全操作次数;上线可按需收紧。 +# 每个来源在窗口内可尝试的登录或安全操作次数;上线可按需收紧。 NETWORK_AUTH_RATE_LIMIT_MAX=30 # 每个来源在窗口内访问单个 OAuth 授权、令牌、撤销或注册端点的次数。 MCP_AUTH_RATE_LIMIT_MAX=100 diff --git a/apps/api/package.json b/apps/api/package.json index 343883f..f783514 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,6 +10,7 @@ "db:migrate": "node scripts/database.cjs deploy", "db:status": "node scripts/database.cjs status", "test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts test/account-deletion-integration.test.ts test/quick-entry-integration.test.ts", + "test:isolated": "node scripts/test-isolated.cjs", "test:performance": "tsx scripts/performance.ts after", "test:mcp": "tsx --test --test-concurrency=1 test/mcp.test.ts test/oauth-duration.test.ts", "mcp:probe": "tsx scripts/mcp-probe.ts", diff --git a/apps/api/prisma/migrations/20261005130000_admin_accounts/migration.sql b/apps/api/prisma/migrations/20261005130000_admin_accounts/migration.sql new file mode 100644 index 0000000..230994c --- /dev/null +++ b/apps/api/prisma/migrations/20261005130000_admin_accounts/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE `User` + ADD COLUMN `role` VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '系统权限:admin、user 或 readonly;管理员不能查看其他用户财务数据', + ADD COLUMN `banned` BOOLEAN NOT NULL DEFAULT false COMMENT '封禁后禁止登录和使用已有会话、MCP 授权', + ADD COLUMN `mustChangePassword` BOOLEAN NOT NULL DEFAULT false COMMENT '首次登录必须设置新的强密码'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 8c9cef0..06a6981 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -13,6 +13,12 @@ model User { username String @unique @db.VarChar(64) /// 登录密码的 bcrypt 哈希,不存储明文 passwordHash String @db.VarChar(255) + /// 系统权限:admin、user 或 readonly;管理员不能查看其他用户财务数据 + role String @default("user") @db.VarChar(16) + /// 封禁后禁止登录和使用已有会话、MCP 授权 + banned Boolean @default(false) + /// 首次登录必须设置新的强密码 + mustChangePassword Boolean @default(false) /// 本位币代码 baseCurrency String @default("CNY") @db.Char(3) /// 隐藏菜单标识列表 diff --git a/apps/api/scripts/test-isolated.cjs b/apps/api/scripts/test-isolated.cjs new file mode 100644 index 0000000..8e40759 --- /dev/null +++ b/apps/api/scripts/test-isolated.cjs @@ -0,0 +1,128 @@ +// Run HTTP integration tests against a disposable MySQL database and API process. +require('dotenv').config({ quiet: true }); +const mysql = require('mysql2/promise'); +const { spawn, spawnSync } = require('node:child_process'); +const { randomBytes } = require('node:crypto'); +const net = require('node:net'); +const fs = require('node:fs'); +const path = require('node:path'); +const pkg = require('../package.json'); +async function main() { + const url = new URL(process.env.DATABASE_URL); + const name = 'wp_test_' + randomBytes(8).toString('hex'); + const connection = await mysql.createConnection({ + host: url.hostname, + port: Number(url.port || 3306), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + }); + let api; + try { + await connection.query('CREATE DATABASE `' + name + '`'); + url.pathname = '/' + name; + const port = await new Promise((resolve, reject) => { + const server = net.createServer(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + server.close(() => resolve(port)); + }); + }); + const env = { + ...process.env, + DATABASE_URL: url.toString(), + ADMIN_USERNAME: 'admin', + ADMIN_PASSWORD: 'admin', + PORT: String(port), + API_HOST: '127.0.0.1', + API_ALLOWED_HOSTS: '*', + WEB_ORIGIN: 'http://localhost:5173', + NETWORK_RATE_LIMIT_ENABLED: 'false', + MCP_PUBLIC_URL: 'http://127.0.0.1:' + port + '/mcp', + MCP_WEB_URL: 'http://localhost:5173', + NETWORK_ALLOW_HTTP: 'true', + TEST_API_URL: 'http://127.0.0.1:' + port + '/api', + }; + const run = (args) => spawnSync(process.execPath, args, { env, encoding: 'utf8' }); + // Historical 005-007 directories sort before the initial migration. Use the + // current schema only in this disposable database, then exercise our SQL. + const migration = run([ + require.resolve('prisma/build/index.js'), + 'db', + 'push', + '--skip-generate', + ]); + if (migration.status !== 0) { + const safe = (migration.stdout + migration.stderr) + .split(/\r?\n/) + .filter((line) => !/Datasource|mysql:\/\/|DATABASE_URL|Environment variables/.test(line)) + .join('\n'); + console.error(safe); + throw Error('Disposable database migration failed'); + } + await connection.query('USE `' + name + '`'); + // db push omits the historical column comments asserted by integration tests. + await connection.query( + "ALTER TABLE `User` MODIFY COLUMN `accountGroupOrder` JSON NULL COMMENT '账户分组显示顺序;空值表示沿用默认顺序'", + ); + await connection.query( + 'ALTER TABLE `User` DROP COLUMN `role`, DROP COLUMN `banned`, DROP COLUMN `mustChangePassword`', + ); + await connection.query( + fs.readFileSync( + path.join(__dirname, '../prisma/migrations/20261005130000_admin_accounts/migration.sql'), + 'utf8', + ), + ); + const build = run([require.resolve('typescript/bin/tsc')]); + if (build.status !== 0) { + console.log(build.stdout); + throw Error('API build failed'); + } + api = spawn(process.execPath, ['dist/main.js'], { env, stdio: 'ignore' }); + let ready = false; + for (let i = 0; i < 80; i++) { + try { + ready = (await fetch(env.TEST_API_URL + '/health')).ok; + } catch {} + if (ready) break; + if (api.exitCode !== null) throw Error('Disposable API startup failed'); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + if (!ready) throw Error('Disposable API startup timed out'); + env.TEST_ISOLATED = 'true'; + const requested = process.argv.slice(2); + const tests = requested.length + ? requested + : [ + ...pkg.scripts['test:integration'].split(' ').filter((s) => s.endsWith('.test.ts')), + 'test/admin-integration.test.ts', + 'test/mcp.test.ts', + 'test/oauth-duration.test.ts', + ]; + const result = spawnSync( + process.execPath, + [require.resolve('tsx/cli'), '--test', '--test-concurrency=1', ...tests], + { env, stdio: 'inherit' }, + ); + process.exitCode = result.status || 0; + } finally { + if (api && api.exitCode === null) { + const exited = new Promise((resolve) => api.once('exit', resolve)); + api.kill(); + await exited; + } + await connection.query('DROP DATABASE `' + name + '`'); + await connection.end(); + console.log('Disposable test database and API cleaned up.'); + } +} +main().catch((e) => { + console.error( + 'Isolated test run failed: ' + + (/Disposable|API build/.test(e.message) + ? e.message + : 'check test configuration or database access'), + ); + process.exitCode = 1; +}); diff --git a/apps/api/src/admin.ts b/apps/api/src/admin.ts new file mode 100644 index 0000000..9b7032b --- /dev/null +++ b/apps/api/src/admin.ts @@ -0,0 +1,123 @@ +import { + Injectable, + Controller, + Get, + Post, + Patch, + Req, + Body, + Param, + Query, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { hash } from 'bcryptjs'; +import { z } from 'zod'; +import { Database } from './database'; +import { UserRequest } from './auth'; +import { adminCreateInput, adminUpdateInput } from './user-access'; + +const summary = { + id: true, + username: true, + role: true, + banned: true, + mustChangePassword: true, + createdAt: true, +} as const; +@Injectable() +export class AdminService { + constructor(private db: Database) {} + private async requireAdmin(userId: string) { + const u = await this.db.user.findUnique({ where: { id: userId } }); + if (!u || u.role !== 'admin' || u.banned || u.mustChangePassword) + throw new ForbiddenException('需要已完成改密的管理员账号'); + } + async list(r: UserRequest, query: unknown) { + await this.requireAdmin(r.userId); + const p = z + .object({ + offset: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(100).default(50), + }) + .parse(query); + const [items, total] = await Promise.all([ + this.db.user.findMany({ + select: summary, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + skip: p.offset, + take: p.limit, + }), + this.db.user.count(), + ]); + return { items, total, offset: p.offset, limit: p.limit }; + } + async create(r: UserRequest, body: unknown) { + await this.requireAdmin(r.userId); + const v = adminCreateInput.parse(body); + const passwordHash = await hash(v.password, 12); + return this.db.serial(async (tx) => { + await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`); + const actor = await tx.user.findUniqueOrThrow({ where: { id: r.userId } }); + if (actor.role !== 'admin' || actor.banned || actor.mustChangePassword) + throw new ForbiddenException('管理员权限已变更'); + return tx.user.create({ + data: { username: v.username, passwordHash, role: v.role, mustChangePassword: true }, + select: summary, + }); + }); + } + async update(r: UserRequest, id: string, body: unknown) { + z.string().uuid().parse(id); + const v = adminUpdateInput.parse(body); + await this.requireAdmin(r.userId); + if (id === r.userId) throw new ForbiddenException('不能修改自己的系统权限或封禁自己'); + return this.db.serial(async (tx) => { + // Serialize administrator changes, including two administrators changing each other. + await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE role='admin' ORDER BY id FOR UPDATE`); + const actor = await tx.user.findUniqueOrThrow({ where: { id: r.userId } }); + if (actor.role !== 'admin' || actor.banned || actor.mustChangePassword) + throw new ForbiddenException('管理员权限已变更'); + const target = await tx.user.findUnique({ where: { id } }); + if (!target) throw new NotFoundException('账号不存在'); + if ( + target.role === 'admin' && + !target.banned && + (v.banned || (v.role && v.role !== 'admin')) && + (await tx.user.count({ where: { role: 'admin', banned: false } })) <= 1 + ) + throw new ForbiddenException('必须保留至少一个可用管理员'); + const result = await tx.user.update({ where: { id }, data: v, select: summary }); + if (result.role !== target.role || result.banned !== target.banned) { + await tx.session.deleteMany({ where: { userId: id } }); + await tx.agentGrant.updateMany({ + where: { userId: id, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + await tx.agentAuthorization.updateMany({ + where: { userId: id, status: { in: ['pending', 'approved'] } }, + data: { status: 'cancelled' }, + }); + await tx.agentOperation.updateMany({ + where: { userId: id, status: 'pending' }, + data: { status: 'cancelled', completedAt: new Date() }, + }); + } + return result; + }); + } +} +@Controller('api/admin/users') +export class AdminController { + constructor(private service: AdminService) {} + @Get() list(@Req() r: UserRequest, @Query() q: unknown) { + return this.service.list(r, q); + } + @Post() create(@Req() r: UserRequest, @Body() b: unknown) { + return this.service.create(r, b); + } + @Patch(':id') update(@Req() r: UserRequest, @Param('id') id: string, @Body() b: unknown) { + return this.service.update(r, id, b); + } +} diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 0e19984..1ac68a5 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -15,6 +15,7 @@ import { ForbiddenException, HttpException, SetMetadata, + OnModuleInit, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Request, Response } from 'express'; @@ -23,6 +24,7 @@ import { hash, compare } from 'bcryptjs'; import { Database } from './database'; import { credentials, credentialChange, defaultOverviewCards } from './validation'; import { Prisma } from '@prisma/client'; +import { loginInput } from './user-access'; export type UserRequest = Request & { userId: string; sessionId: string; @@ -40,9 +42,26 @@ export function allowedOrigin( return isNetworkOriginAllowed(origin, configured, !production); } @Injectable() -export class AuthService { +export class AuthService implements OnModuleInit { private attempts = new Map(); constructor(private db: Database) {} + async onModuleInit() { + // Never reset or promote an existing account based on environment defaults. + const username = credentials.shape.username.parse(process.env.ADMIN_USERNAME ?? 'admin'); + const password = loginInput.shape.password.parse(process.env.ADMIN_PASSWORD ?? 'admin'); + if (await this.db.user.count({ where: { role: 'admin' } })) return; + const passwordHash = await hash(password, 12); + await this.db.serial(async (tx) => { + if (await tx.user.count({ where: { role: 'admin' } })) return; + if (await tx.user.findUnique({ where: { username } })) + throw new Error( + 'Default administrator username already exists; configure a different ADMIN_USERNAME', + ); + await tx.user.create({ + data: { username, passwordHash, role: 'admin', mustChangePassword: true }, + }); + }); + } limit(req: Request) { const network = networkConfig(); if (!network.rateLimitEnabled) return; @@ -84,8 +103,9 @@ export class AuthService { if (!s || s.expiresAt <= new Date()) return null; const u = await this.db.user.findUniqueOrThrow({ where: { id: s.userId }, - select: { idleMinutes: true }, + select: { idleMinutes: true, banned: true }, }); + if (u.banned) throw new UnauthorizedException('账号已被封禁'); if (u.idleMinutes && Date.now() - +s.lastActivity >= u.idleMinutes * 60000) { await this.db.session.deleteMany({ where: { id: s.id } }); throw new UnauthorizedException('长时间无操作,已自动退出登录'); @@ -102,6 +122,29 @@ export class AuthService { httpOnly: true, }); } + async access(req: UserRequest) { + const u = await this.db.user.findUniqueOrThrow({ where: { id: req.userId } }); + if (u.banned) throw new UnauthorizedException('账号已被封禁'); + const path = req.path.replace(/\/$/, ''); + const passwordAllowed = [ + '/api/auth/me', + '/api/auth/credentials', + '/api/auth/logout', + '/api/auth/activity', + ]; + if (u.mustChangePassword && !passwordAllowed.includes(path)) + throw new ForbiddenException('首次登录必须修改密码'); + const connectionManagement = + path === '/api/agent/tokens' || + /^\/api\/agent\/(authorizations|connections)\/[a-f0-9-]{36}$/.test(path); + if ( + u.role === 'readonly' && + !['GET', 'HEAD', 'OPTIONS'].includes(req.method) && + !path.startsWith('/api/auth/') && + !connectionManagement + ) + throw new ForbiddenException('只读账号不能修改数据'); + } } @Injectable() export class AuthGuard implements CanActivate { @@ -126,6 +169,7 @@ export class AuthGuard implements CanActivate { req.userId = id.userId; req.sessionId = id.id; req.revealed = !!id.revealUntil && +id.revealUntil > Date.now(); + await this.auth.access(req); return true; } } @@ -140,30 +184,34 @@ export class AuthBusinessService { return { status: 'ok' }; } async register(body: unknown, req: Request, res: Response) { - this.auth.limit(req); - const v = credentials.parse(body), - user = await this.db.user.create({ - data: { username: v.username, passwordHash: await hash(v.password, 12) }, - }); - await this.auth.issue(user.id, res); - return { username: user.username, baseCurrency: user.baseCurrency }; + throw new ForbiddenException('公开注册已关闭,请联系管理员创建账号'); } async login(body: unknown, req: Request, res: Response) { this.auth.limit(req); - const v = credentials.parse(body), + const v = loginInput.parse(body), user = await this.db.user.findUnique({ where: { username: v.username } }); const ok = await compare( v.password, user?.passwordHash || '$2b$12$JZKvzAzfqM3obKxMQTFBjOzqIIKG97kn96/xUMbgTuWUnlMAT2bSi', ); if (!user || !ok) throw new UnauthorizedException('账号或密码错误'); + if (user.banned) throw new ForbiddenException('账号已被封禁'); await this.auth.issue(user.id, res); - return { username: user.username, baseCurrency: user.baseCurrency }; + return { + username: user.username, + baseCurrency: user.baseCurrency, + role: user.role, + mustChangePassword: user.mustChangePassword, + hiddenMenus: [], + }; } async me(req: UserRequest) { const user = await this.db.user.findUniqueOrThrow({ where: { id: req.userId }, select: { + id: true, + role: true, + mustChangePassword: true, username: true, baseCurrency: true, hiddenMenus: true, @@ -195,6 +243,11 @@ export class AuthBusinessService { const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } }); if (!(await compare(v.currentPassword, user.passwordHash))) throw new ForbiddenException('当前密码错误'); + if ( + user.mustChangePassword && + (!v.newPassword || (await compare(v.newPassword, user.passwordHash))) + ) + throw new BadRequestException('请设置与初始密码不同的新密码(至少 10 个字符)'); if ((!v.username || v.username === user.username) && !v.newPassword) throw new BadRequestException('请填写新的账号或密码'); const passwordHash = v.newPassword ? await hash(v.newPassword, 12) : user.passwordHash; @@ -203,11 +256,23 @@ export class AuthBusinessService { await this.db.serial(async (tx) => { await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`); const current = await tx.user.findUniqueOrThrow({ where: { id: r.userId } }); - if (current.passwordHash !== user.passwordHash || current.username !== user.username) + if ( + current.banned || + current.passwordHash !== user.passwordHash || + current.username !== user.username + ) throw new ForbiddenException('账号已变更,请重新登录后操作'); await tx.user.update({ where: { id: r.userId }, - data: { username: v.username, passwordHash }, + data: { + username: v.username, + passwordHash, + ...(v.newPassword ? { mustChangePassword: false } : {}), + }, + }); + await tx.agentGrant.updateMany({ + where: { userId: r.userId, revokedAt: null }, + data: { revokedAt: new Date() }, }); await tx.session.deleteMany({ where: { userId: r.userId } }); await tx.session.create({ data: { id: digest(token), userId: r.userId, expiresAt } }); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 51ec915..96af907 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -8,6 +8,7 @@ import cookieParser from 'cookie-parser'; import helmet from 'helmet'; import { json } from 'express'; import { AuthController, AuthBusinessService, AuthGuard, AuthService } from './auth'; +import { AdminController, AdminService } from './admin'; import { CalendarController, CalendarBusinessService } from './calendar'; import { SchedulesController, SchedulesBusinessService } from './schedules'; import { TransfersController, TransfersBusinessService } from './transfers'; @@ -57,6 +58,7 @@ class SafeErrors implements ExceptionFilter { providers: [ Database, AuthService, + AdminService, RatesService, MetalsService, IconsService, @@ -82,6 +84,7 @@ class SafeErrors implements ExceptionFilter { CalendarController, IconsController, AuthController, + AdminController, PortfolioController, MetalsController, SettingsController, diff --git a/apps/api/src/mcp/oauth.ts b/apps/api/src/mcp/oauth.ts index 44ce1a0..794ff3b 100644 --- a/apps/api/src/mcp/oauth.ts +++ b/apps/api/src/mcp/oauth.ts @@ -178,6 +178,15 @@ export class AgentOAuth implements OAuthServerProvider { const authorizationDays = oauthDays.parse(days); const allowed = selected || ['read']; scopeInput.parse(allowed); + const user = await this.db.user.findUnique({ where: { id: userId } }); + if (!user || user.banned || user.mustChangePassword) + throw new ForbiddenException('账号不可用'); + if ( + approved && + user.role === 'readonly' && + allowed.some((s: string) => ['draft', 'write', 'hidden_write'].includes(s)) + ) + throw new ForbiddenException('只读账号只能授予查询权限'); if (allowed.some((scope: string) => !parameters.scopes.includes(scope))) throw new BadRequestException('不能授予客户端未请求的权限'); const code = secret(); @@ -220,6 +229,14 @@ export class AgentOAuth implements OAuthServerProvider { authorizationDays: number | null = 30, ) { if (clientId && days === null) throw new BadRequestException('OAuth 连接必须有期限'); + const user = await this.db.user.findUnique({ where: { id: userId } }); + if (!user || user.banned || user.mustChangePassword) + throw new ForbiddenException('账号已封禁或需要首次改密'); + if ( + user.role === 'readonly' && + selected.some((s) => ['draft', 'write', 'hidden_write'].includes(s)) + ) + throw new ForbiddenException('只读账号只能授予查询权限'); const access = secret(), refresh = clientId ? secret() : undefined, sessionId = digest(secret()); @@ -315,6 +332,9 @@ export class AgentOAuth implements OAuthServerProvider { (row.refreshExpiresAt && row.refreshExpiresAt <= new Date()) ) throw new InvalidGrantError('Invalid refresh token'); + const user = await this.db.user.findUnique({ where: { id: row.userId } }); + if (!user || user.banned || user.mustChangePassword) + throw new InvalidGrantError('Account unavailable'); const current = row.scopes as string[]; if ( selected && @@ -368,6 +388,9 @@ export class AgentOAuth implements OAuthServerProvider { row.resource !== urls().resource.toString() ) throw new InvalidTokenError('Expired, revoked or invalid resource token'); + const user = await this.db.user.findUnique({ where: { id: row.userId } }); + if (!user || user.banned || user.mustChangePassword) + throw new InvalidTokenError('Account unavailable'); return { token, clientId: row.clientId || row.id, @@ -407,6 +430,9 @@ export class AgentOAuth implements OAuthServerProvider { }, }); if (!row) throw new ForbiddenException('Agent 连接已过期或撤销'); + const user = await this.db.user.findUnique({ where: { id: row.userId } }); + if (!user || user.banned || user.mustChangePassword) + throw new ForbiddenException('账号已封禁或需要首次改密'); return row; } } diff --git a/apps/api/src/mcp/operations.ts b/apps/api/src/mcp/operations.ts index 52b424b..971104f 100644 --- a/apps/api/src/mcp/operations.ts +++ b/apps/api/src/mcp/operations.ts @@ -134,6 +134,10 @@ export class AgentOperations { return digest(stable(plain(data))); } private async permission(grant: AgentGrant, t: ToolDefinition, p: any) { + const user = await this.db.user.findUniqueOrThrow({ where: { id: grant.userId } }); + if (user.banned || user.mustChangePassword) throw new ForbiddenException('账号不可用'); + if (user.role === 'readonly' && t.scope !== 'read') + throw new ForbiddenException('只读账号不能提交写入或草稿'); const selected = grant.scopes as string[]; if (!selected.includes('read')) throw new ForbiddenException('缺少 read 权限'); const mode = selected.includes('write') diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 243afb0..06b7a8c 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -3,6 +3,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { metalConfig, metalHoldingInput } from './metals'; import { scheduleInput } from './schedules'; import { z } from 'zod'; +import { loginInput, adminCreateInput, adminUpdateInput } from './user-access'; import { credentials, credentialChange, @@ -32,8 +33,9 @@ export function setupOpenApi(app: INestApplication) { .build(), ); const bodies: Record = { - 'POST /api/auth/register': credentials, - 'POST /api/auth/login': credentials, + 'POST /api/auth/login': loginInput, + 'POST /api/admin/users': adminCreateInput, + 'PATCH /api/admin/users/{id}': adminUpdateInput, 'PATCH /api/auth/credentials': credentialChange, 'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(), 'POST /api/positions': positionInput, diff --git a/apps/api/src/rates.ts b/apps/api/src/rates.ts index 616e65b..f8d2fa3 100644 --- a/apps/api/src/rates.ts +++ b/apps/api/src/rates.ts @@ -183,6 +183,9 @@ export class SettingsBusinessService { where: { id: r.userId }, select: { username: true, + id: true, + role: true, + mustChangePassword: true, baseCurrency: true, hiddenMenus: true, showNotes: true, diff --git a/apps/api/src/user-access.ts b/apps/api/src/user-access.ts new file mode 100644 index 0000000..ce92e88 --- /dev/null +++ b/apps/api/src/user-access.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { credentials } from './validation'; + +export const roles = z.enum(['admin', 'user', 'readonly']); +export const loginInput = credentials.extend({ + password: z + .string() + .min(1) + .max(72) + .refine((s) => Buffer.byteLength(s, 'utf8') <= 72, '密码最多 72 字节'), +}); +export const adminCreateInput = credentials.extend({ role: roles.default('user') }).strict(); +export const adminUpdateInput = z + .object({ role: roles.optional(), banned: z.boolean().optional() }) + .strict() + .refine((v) => v.role !== undefined || v.banned !== undefined, '请选择权限或封禁状态'); diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index c34b587..ed7094c 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -177,7 +177,11 @@ export const pairedReasons = [ export const credentialChange = z .object({ - currentPassword: credentials.shape.password, + currentPassword: z + .string() + .min(1) + .max(72) + .refine((s) => Buffer.byteLength(s, 'utf8') <= 72, '密码最多 72 字节'), username: credentials.shape.username.optional(), newPassword: credentials.shape.password.optional(), }) diff --git a/apps/api/test/admin-integration.test.ts b/apps/api/test/admin-integration.test.ts new file mode 100644 index 0000000..921e803 --- /dev/null +++ b/apps/api/test/admin-integration.test.ts @@ -0,0 +1,235 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PrismaClient } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; +import { AuthService } from '../src/auth'; +import { Database } from '../src/database'; +import { AgentOAuth } from '../src/mcp/oauth'; + +test( + 'admin bootstrap, closed registration, mandatory password change, roles and ban revoke sessions and MCP', + { skip: process.env.TEST_ISOLATED !== 'true' }, + async () => { + const db = new PrismaClient(); + const base = process.env.TEST_API_URL!; + const origin = process.env.WEB_ORIGIN!; + async function call(path: string, method = 'GET', body?: unknown, cookie = '') { + const r = await fetch(base + path, { + method, + headers: { + Origin: origin, + Cookie: cookie, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + return { + status: r.status, + data: await r.json(), + cookie: r.headers.get('set-cookie')?.split(';')[0] || cookie, + }; + } + try { + assert.equal( + (await call('/auth/register', 'POST', { username: 'blocked', password: 'long-password' })) + .status, + 403, + ); + assert.equal(await db.user.count({ where: { username: 'blocked' } }), 0); + const initial = await db.user.findUniqueOrThrow({ where: { username: 'admin' } }); + assert.equal(initial.role, 'admin'); + assert.equal(initial.mustChangePassword, true); + const login = await call('/auth/login', 'POST', { username: 'admin', password: 'admin' }); + assert.equal(login.status, 201); + assert.equal(login.data.mustChangePassword, true); + assert.equal((await call('/overview', 'GET', undefined, login.cookie)).status, 403); + assert.equal((await call('/admin/users', 'GET', undefined, login.cookie)).status, 403); + assert.equal( + ( + await call( + '/agent/tokens', + 'POST', + { name: 'blocked', scopes: ['read'], days: 1, password: 'admin' }, + login.cookie, + ) + ).status, + 403, + ); + assert.equal( + ( + await call( + '/auth/credentials', + 'PATCH', + { currentPassword: 'admin', username: 'renamed' }, + login.cookie, + ) + ).status, + 400, + ); + assert.equal( + ( + await call( + '/auth/credentials', + 'PATCH', + { currentPassword: 'admin', newPassword: 'short' }, + login.cookie, + ) + ).status, + 400, + ); + const password = 'Admin-new-' + randomUUID(); + const changed = await call( + '/auth/credentials', + 'PATCH', + { currentPassword: 'admin', newPassword: password }, + login.cookie, + ); + assert.equal(changed.status, 200); + const cookie = changed.cookie; + assert.equal( + (await call('/auth/me', 'GET', undefined, cookie)).data.mustChangePassword, + false, + ); + assert.equal((await call('/auth/me', 'GET', undefined, login.cookie)).status, 401); + const savedHash = (await db.user.findUniqueOrThrow({ where: { id: initial.id } })) + .passwordHash; + await new AuthService(db as Database).onModuleInit(); + assert.equal( + (await db.user.findUniqueOrThrow({ where: { id: initial.id } })).passwordHash, + savedHash, + ); + assert.equal( + (await call('/auth/login', 'POST', { username: 'admin', password: 'admin' })).status, + 401, + ); + assert.equal( + (await call('/admin/users/' + initial.id, 'PATCH', { banned: true }, cookie)).status, + 403, + ); + const username = 'admin_test_' + randomUUID().slice(0, 10), + first = 'Initial-' + randomUUID(); + const created = await call( + '/admin/users', + 'POST', + { username, password: first, role: 'user' }, + cookie, + ); + assert.equal(created.status, 201); + assert.equal(created.data.mustChangePassword, true); + assert.equal('passwordHash' in created.data, false); + const id = created.data.id; + assert.equal( + (await call('/admin/users', 'POST', { username, password: first }, cookie)).status, + 409, + ); + assert.equal( + ( + await call( + '/admin/users', + 'POST', + { username: username + '_bad', password: first, role: 'superuser' }, + cookie, + ) + ).status, + 400, + ); + const fresh = await call('/auth/login', 'POST', { username, password: first }); + assert.equal((await call('/positions', 'GET', undefined, fresh.cookie)).status, 403); + assert.equal( + ( + await call( + '/auth/credentials', + 'PATCH', + { currentPassword: first, newPassword: first }, + fresh.cookie, + ) + ).status, + 400, + ); + const regularPassword = 'Changed-' + randomUUID(); + const updated = await call( + '/auth/credentials', + 'PATCH', + { currentPassword: first, newPassword: regularPassword }, + fresh.cookie, + ); + assert.equal(updated.status, 200); + const regular = updated.cookie; + assert.equal((await call('/admin/users', 'GET', undefined, regular)).status, 403); + assert.equal( + ( + await call( + '/admin/users', + 'POST', + { username: 'escalate', password: first, role: 'admin' }, + regular, + ) + ).status, + 403, + ); + const token = await call( + '/agent/tokens', + 'POST', + { name: 'ban-test', scopes: ['read', 'write'], days: 1, password: regularPassword }, + regular, + ); + assert.equal(token.status, 201); + const oauth = new AgentOAuth(db as Database); + await oauth.verifyAccessToken(token.data.token); + assert.equal( + (await call('/admin/users/' + id, 'PATCH', { role: 'readonly' }, cookie)).status, + 200, + ); + assert.equal((await call('/auth/me', 'GET', undefined, regular)).status, 401); + await assert.rejects(oauth.verifyAccessToken(token.data.token)); + const readonly = await call('/auth/login', 'POST', { username, password: regularPassword }); + assert.equal((await call('/positions', 'GET', undefined, readonly.cookie)).status, 200); + assert.equal((await call('/positions', 'POST', {}, readonly.cookie)).status, 403); + assert.equal((await call('/schedules/run', 'POST', {}, readonly.cookie)).status, 403); + assert.equal( + ( + await call( + '/agent/tokens', + 'POST', + { name: 'escalate', scopes: ['read', 'draft'], days: 1, password: regularPassword }, + readonly.cookie, + ) + ).status, + 403, + ); + const readToken = await call( + '/agent/tokens', + 'POST', + { name: 'readonly', scopes: ['read'], days: 1, password: regularPassword }, + readonly.cookie, + ); + assert.equal(readToken.status, 201); + const readInfo = await oauth.verifyAccessToken(readToken.data.token); + assert.deepEqual(readInfo.scopes, ['read']); + const list = await call('/admin/users?limit=1&offset=1', 'GET', undefined, cookie); + assert.equal(list.data.items.length, 1); + assert.ok(list.data.total >= 2); + assert.equal( + (await call('/admin/users/' + id, 'PATCH', { banned: true }, cookie)).status, + 200, + ); + assert.equal( + (await call('/auth/login', 'POST', { username, password: regularPassword })).status, + 403, + ); + assert.equal((await call('/auth/me', 'GET', undefined, readonly.cookie)).status, 401); + await assert.rejects(oauth.verifyAccessToken(readToken.data.token)); + assert.equal(await db.session.count({ where: { userId: id } }), 0); + assert.equal( + (await call('/admin/users/' + id, 'PATCH', { banned: false, role: 'user' }, cookie)).status, + 200, + ); + const unbanned = await call('/auth/login', 'POST', { username, password: regularPassword }); + assert.equal(unbanned.status, 201); + assert.equal((await call('/overview', 'GET', undefined, unbanned.cookie)).status, 200); + } finally { + await db.$disconnect(); + } + }, +); diff --git a/apps/api/test/codex-oauth.test.ts b/apps/api/test/codex-oauth.test.ts index 6300270..915974c 100644 --- a/apps/api/test/codex-oauth.test.ts +++ b/apps/api/test/codex-oauth.test.ts @@ -1,3 +1,4 @@ +import { provisionTestUser } from './user-fixture'; import 'dotenv/config'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -62,7 +63,8 @@ for (const useDefaultScopes of [false, true]) } } try { - const fixture = await web('/auth/register', { username, password }); + await provisionTestUser({ username, password }); + const fixture = await web('/auth/login', { username, password }); userId = (await db.user.findUniqueOrThrow({ where: { username } })).id; await writeFile( join(home, 'config.toml'), diff --git a/apps/api/test/icons.test.ts b/apps/api/test/icons.test.ts index 0a834da..e6d82f0 100644 --- a/apps/api/test/icons.test.ts +++ b/apps/api/test/icons.test.ts @@ -1,3 +1,4 @@ +import { provisionTestUser } from './user-fixture'; import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; @@ -32,11 +33,13 @@ test('private and shared icons, account reuse and complete ZIP restoration prese }; } async function account() { - const username = 'wp_icons_' + randomUUID(); + const username = 'wp_icons_' + randomUUID(), + password = randomBytes(18).toString('hex'); names.push(username); - const r = await call('/auth/register', '', 'POST', { + await provisionTestUser({ username, password }); + const r = await call('/auth/login', '', 'POST', { username, - password: randomBytes(18).toString('hex'), + password, }); assert.equal(r.status, 201); return { ...r, id: (await db.user.findUniqueOrThrow({ where: { username } })).id }; diff --git a/apps/api/test/integration.test.ts b/apps/api/test/integration.test.ts index 404fa0b..d63518b 100644 --- a/apps/api/test/integration.test.ts +++ b/apps/api/test/integration.test.ts @@ -1,3 +1,4 @@ +import { provisionTestUser } from './user-fixture'; import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; @@ -32,7 +33,8 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures async function account() { const username = 'wp_test_' + randomUUID().slice(0, 12), password = randomBytes(18).toString('hex'); - const r = await call('/auth/register', 'POST', { username, password }); + await provisionTestUser({ username, password }); + const r = await call('/auth/login', 'POST', { username, password }); assert.equal(r.status, 201); assert.ok(r.cookie); const u = await db.user.findUniqueOrThrow({ where: { username } }); diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts index 8d7c4f1..bab4bbb 100644 --- a/apps/api/test/mcp.test.ts +++ b/apps/api/test/mcp.test.ts @@ -1,3 +1,4 @@ +import { provisionTestUser } from './user-fixture'; import { networkConfig, isNetworkOriginAllowed } from '../src/network'; import 'dotenv/config'; import { test } from 'node:test'; @@ -44,7 +45,8 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol ) { const username = 'mcp_test_' + randomUUID().slice(0, 12), password = randomBytes(20).toString('hex'); - const registered = await web('', '/auth/register', 'POST', { username, password }); + await provisionTestUser({ username, password }); + const registered = await web('', '/auth/login', 'POST', { username, password }); assert.equal(registered.status, 201); const user = await db.user.findUniqueOrThrow({ where: { username } }); users.push(user.id); @@ -705,7 +707,8 @@ test('real concurrent MCP idempotency, stale writes, icon workflow, nested rollb }); } try { - assert.equal((await web('/auth/register', 'POST', { username, password })).status, 201); + await provisionTestUser({ username, password }); + assert.equal((await web('/auth/login', 'POST', { username, password })).status, 201); userId = (await db.user.findUniqueOrThrow({ where: { username } })).id; const grant = ( await web('/agent/tokens', 'POST', { @@ -902,7 +905,8 @@ test('official SDK OAuth discovery, DCR, PKCE, consent, one-use codes, rotation }; } try { - const registered = await post('/api/auth/register', { username, password }); + await provisionTestUser({ username, password }); + const registered = await post('/api/auth/login', { username, password }); assert.equal(registered.status, 201); userId = (await db.user.findUniqueOrThrow({ where: { username } })).id; assert.equal(await auth(provider, { serverUrl: resource, scope: 'read write' }), 'REDIRECT'); diff --git a/apps/api/test/privacy.test.ts b/apps/api/test/privacy.test.ts index 353ab5c..2b7eb4f 100644 --- a/apps/api/test/privacy.test.ts +++ b/apps/api/test/privacy.test.ts @@ -1,3 +1,4 @@ +import { provisionTestUser } from './user-fixture'; import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; @@ -31,7 +32,8 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user const username = 'wp_privacy_' + randomUUID(), password = randomBytes(18).toString('hex'); names.push(username); - const r = await call('/auth/register', 'POST', { username, password }); + await provisionTestUser({ username, password }); + const r = await call('/auth/login', 'POST', { username, password }); assert.equal(r.status, 201); const u = await db.user.findUniqueOrThrow({ where: { username } }); return { ...r, username, password, id: u.id }; diff --git a/apps/api/test/transfers.test.ts b/apps/api/test/transfers.test.ts index 002ca39..971e644 100644 --- a/apps/api/test/transfers.test.ts +++ b/apps/api/test/transfers.test.ts @@ -1,3 +1,4 @@ +import { provisionTestUser } from './user-fixture'; import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; @@ -33,7 +34,8 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba const username = 'wp_transfer_' + randomUUID(), password = randomBytes(18).toString('hex'); names.push(username); - const r = await call('/auth/register', '', 'POST', { username, password }); + await provisionTestUser({ username, password }); + const r = await call('/auth/login', '', 'POST', { username, password }); assert.equal(r.status, 201); return { ...r, id: (await db.user.findUniqueOrThrow({ where: { username } })).id, password }; } diff --git a/apps/api/test/update-integration.test.ts b/apps/api/test/update-integration.test.ts index cbc092e..37bab03 100644 --- a/apps/api/test/update-integration.test.ts +++ b/apps/api/test/update-integration.test.ts @@ -1,3 +1,4 @@ +import { provisionTestUser } from './user-fixture'; import { fixtureFetch } from './backup-fixture'; import 'dotenv/config'; import { test } from 'node:test'; @@ -34,7 +35,8 @@ test('real MySQL: groups, scheduled atomic execution, calendar, privacy and ZIP const username = 'wp_update_' + randomUUID(), password = randomBytes(18).toString('hex'); names.push(username); - const result = await call('/auth/register', '', 'POST', { username, password }); + await provisionTestUser({ username, password }); + const result = await call('/auth/login', '', 'POST', { username, password }); assert.equal(result.status, 201); return { cookie: result.cookie, diff --git a/apps/api/test/user-fixture.ts b/apps/api/test/user-fixture.ts new file mode 100644 index 0000000..18c4620 --- /dev/null +++ b/apps/api/test/user-fixture.ts @@ -0,0 +1,14 @@ +import { PrismaClient } from '@prisma/client'; +import { hash } from 'bcryptjs'; + +// Provision test data directly; production registration remains disabled. +export async function provisionTestUser(input: { username: string; password: string }) { + const db = new PrismaClient(); + try { + return await db.user.create({ + data: { username: input.username, passwordHash: await hash(input.password, 12) }, + }); + } finally { + await db.$disconnect(); + } +} diff --git a/apps/web/src/AdminPanel.tsx b/apps/web/src/AdminPanel.tsx new file mode 100644 index 0000000..9b1fe93 --- /dev/null +++ b/apps/web/src/AdminPanel.tsx @@ -0,0 +1,270 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { api, type User } from './api'; +import { t as tr } from './i18n'; +type Account = { + id: string; + username: string; + role: string; + banned: boolean; + mustChangePassword: boolean; +}; +const roleLabels: Record = { + admin: '管理员', + user: '普通用户', + readonly: '只读用户', +}; +export function AdminPanel({ user, report }: { user: User; report: (e: unknown) => void }) { + const [items, setItems] = useState([]), + [total, setTotal] = useState(0), + [offset, setOffset] = useState(0); + const [busy, setBusy] = useState(false), + [version, setVersion] = useState(0); + useEffect(() => { + let cancelled = false; + setBusy(true); + void api<{ items: Account[]; total: number }>('/admin/users?limit=50&offset=' + offset) + .then((data) => { + if (!cancelled) { + setItems(data.items); + setTotal(data.total); + } + }) + .catch((e) => { + if (!cancelled) report(e); + }) + .finally(() => { + if (!cancelled) setBusy(false); + }); + return () => { + cancelled = true; + }; + }, [offset, version]); + async function create(e: FormEvent) { + e.preventDefault(); + const form = e.currentTarget, + data = new FormData(form); + setBusy(true); + try { + await api('/admin/users', 'POST', { + username: data.get('username'), + password: data.get('password'), + role: data.get('role'), + }); + form.reset(); + setVersion((v) => v + 1); + } catch (e) { + report(e); + } finally { + setBusy(false); + } + } + async function change(account: Account, data: { role?: string; banned?: boolean }) { + setBusy(true); + try { + await api('/admin/users/' + account.id, 'PATCH', data); + setVersion((v) => v + 1); + } catch (e) { + report(e); + } finally { + setBusy(false); + } + } + return ( +
+
+

{tr('添加账号')}

+

{tr('新账号首次登录必须修改初始密码。')}

+
+ + + + +
+

+ {tr('管理员管理账号;普通用户可管理自己的数据;只读用户可查询数据并修改自己的登录密码。')} +

+
+
+

+ {tr('账号管理')} · {total} +

+

+ {tr('修改权限或封禁后,该账号的登录会话与 MCP 授权会撤销,需要重新登录或授权。')} +

+
+ + + + + + + + + + + {items.map((account) => ( + + + + + + + ))} + +
{tr('账号')}{tr('账号权限')}{tr('状态')}{tr('操作')}
+ {account.username} + {account.id === user.id && · {tr('当前账号')}} + + + + {account.banned + ? tr('已封禁') + : account.mustChangePassword + ? tr('待首次改密') + : tr('正常')} + + +
+
+
+ + +
+
+
+ ); +} +export function FirstPassword({ + changed, + logout, + report, +}: { + changed: (u: User) => void; + logout: () => void; + report: (e: unknown) => void; +}) { + const [busy, setBusy] = useState(false); + async function submit(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + if (f.get('newPassword') !== f.get('repeatPassword')) { + report(new Error(tr('两次输入的新密码不一致'))); + return; + } + setBusy(true); + try { + await api('/auth/credentials', 'PATCH', { + currentPassword: f.get('currentPassword'), + newPassword: f.get('newPassword'), + }); + changed(await api('/auth/me')); + } catch (e) { + report(e); + } finally { + setBusy(false); + } + } + return ( +
+
+

{tr('首次登录,请修改密码')}

+

+ {tr('设置与初始密码不同的新密码后,即可进入系统。新密码至少 10 个字符,最多 72 字节。')} +

+
+ + + + +
+ +
+
+ ); +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 89f992f..da547ab 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -58,6 +58,7 @@ import { AgentConnections } from './AgentConnections'; import { AgentAuthorization } from './AgentReview'; import { AgentDrafts } from './AgentDrafts'; import { savedLogin, rememberLogin } from './login-preferences'; +import { AdminPanel, FirstPassword } from './AdminPanel'; import { PositionDeletion, deletionLabel } from './PositionDeletion'; import { IconPicker } from './IconPicker'; import { MetalPanel } from './MetalPanel'; @@ -84,6 +85,7 @@ const labels: Record = { calendar: '收支日历', schedules: '定时计划', settings: '设置与备份', + admin: '管理员后台', }; const categories: Record = { account: [ @@ -176,7 +178,6 @@ export default function App() { const authCheck = useRef | null>(null); const [user, setUser] = useState(null), [boot, setBoot] = useState(true), - [register, setRegister] = useState(false), [page, setPage] = useState( new URLSearchParams(location.search).has('agent_authorization') ? 'agent-review' : 'overview', ), @@ -498,7 +499,6 @@ export default function App() { setBackup(null); setPreview(null); setPage('overview'); - setRegister(false); setLoading(false); setSuccess(''); setQuickTransfer(null); @@ -521,6 +521,10 @@ export default function App() { try { const s = refreshUser ? await api('/auth/me') : userRef.current; if (!active() || !s) return; + if (s.mustChangePassword) { + setUser(s); + return; + } const visibilityChanged = !!s.revealed !== !!userRef.current?.revealed; if (visibilityChanged) rangeOnly = false; if (refreshUser) { @@ -561,7 +565,7 @@ export default function App() { const rows = await api('/positions?kind=account'); if (active()) setPositions(rows); } else if (['account', 'asset', 'debt'].includes(page)) { - if (page === 'account' && !rangeOnly && runDue) { + if (page === 'account' && !rangeOnly && runDue && s.role !== 'readonly') { const result = await api<{ executed: number; errors: { message: string }[]; @@ -703,7 +707,7 @@ export default function App() { }, [modal]); const previousView = useRef(''); useEffect(() => { - if (!user) return; + if (!user || user.mustChangePassword) return; const mutated = previousDataVersion.current !== dataVersion; previousDataVersion.current = dataVersion; const rangeOnly = @@ -718,7 +722,7 @@ export default function App() { return () => { loadGeneration.current++; }; - }, [viewKey, !!user, dataVersion]); + }, [viewKey, !!user, user?.mustChangePassword, dataVersion]); useEffect(() => { const session = sessionGeneration.current; authCheck.current ||= api('/auth/me'); @@ -829,13 +833,12 @@ export default function App() { setBusy(true); setError(''); try { - const u = await api('/auth/' + (register ? 'register' : 'login'), 'POST', { + const u = await api('/auth/login', 'POST', { username: f.get('username'), password: f.get('password'), }); if (session !== sessionGeneration.current) return; - if (!register) - void rememberLogin(String(f.get('username')), String(f.get('password')), remember); + void rememberLogin(String(f.get('username')), String(f.get('password')), remember); const agentReturn = new URLSearchParams(location.search).has('agent_authorization'); setPage(agentReturn ? 'agent-review' : 'overview'); if (agentReturn) setSettingsSection('agent'); @@ -911,10 +914,8 @@ export default function App() {

{tr('开始你的资产之路')}

-

{register ? tr('创建账号') : tr('欢迎回来')}

-

- {register ? tr('建立属于你的私人资产空间') : tr('登录以查看你的资产与负债')} -

+

{tr('欢迎回来')}

+

{tr('登录以查看你的资产与负债')}

@@ -932,14 +933,14 @@ export default function App() { - {!register && ( + { <>
); + if (user.mustChangePassword) + return void logout()} report={report} />; async function correctHistory(h: History) { if (h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')) { const session = sessionGeneration.current, @@ -1060,8 +1052,11 @@ export default function App() { ['calendar', CalendarDays], ['schedules', HistoryIcon], ['settings', Settings], + ['admin', ShieldCheck], ] as const; - const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key)); + const visibleNav = nav.filter(([key]) => + key === 'admin' ? user.role === 'admin' : !(user.hiddenMenus || []).includes(key), + ); const groups = orderedGroups( positions.filter((p) => p.kind === 'account').map((p) => p.groupName || ''), user.accountGroupOrder, @@ -1181,18 +1176,24 @@ export default function App() {

- {page === 'account' && !p && !quickMode && ( + {user.role !== 'readonly' && page === 'account' && !p && !quickMode && ( )} - {['overview', 'account', 'asset', 'debt'].includes(page) && + {user.role !== 'readonly' && + ['overview', 'account', 'asset', 'debt'].includes(page) && !p && !(page === 'account' && quickMode) && newAction}
+ {user.role === 'readonly' && ( +

+ {tr('当前为只读账号,无法修改业务数据。')} +

+ )} {loading && (

{tr('正在刷新数据…')} @@ -2128,6 +2129,7 @@ export default function App() { {page === 'calendar' && ( )} + {page === 'admin' && user.role === 'admin' && } {page === 'settings' && ( <>