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 授权会撤销,需要重新登录或授权。')}
+
+
+
+
+
+ | {tr('账号')} |
+ {tr('账号权限')} |
+ {tr('状态')} |
+ {tr('操作')} |
+
+
+
+ {items.map((account) => (
+
+ |
+ {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('登录以查看你的资产与负债')}
-
- {register ? tr('已有账号?') : tr('还没有账号?')}{' '}
-
-
+
{tr('账号由管理员创建,请联系管理员获取账号。')}
{tr('个人数据按登录身份隔离')}
@@ -977,6 +967,8 @@ export default function App() {
);
+ 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' && (
<>