feat: add admin user management and disable public registration
This commit is contained in:
1 parent
cfbba73791
commit
312a5ccb86
32 files changed
+1120
-69
No files matched your search
@@ -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)。
|
||||
|
||||
## 账户分组、定时计划与收支日历
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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 '首次登录必须设置新的强密码';
|
||||
@@ -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)
|
||||
/// 隐藏菜单标识列表
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+78
-13
@@ -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<string, { count: number; until: number }>();
|
||||
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 } });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, z.ZodType> = {
|
||||
'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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, '请选择权限或封禁状态');
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
admin: '管理员',
|
||||
user: '普通用户',
|
||||
readonly: '只读用户',
|
||||
};
|
||||
export function AdminPanel({ user, report }: { user: User; report: (e: unknown) => void }) {
|
||||
const [items, setItems] = useState<Account[]>([]),
|
||||
[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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="admin-panel">
|
||||
<section className="panel">
|
||||
<h2>{tr('添加账号')}</h2>
|
||||
<p className="muted">{tr('新账号首次登录必须修改初始密码。')}</p>
|
||||
<form onSubmit={create} className="admin-create">
|
||||
<label className="field">
|
||||
<span>{tr('账号')}</span>
|
||||
<input name="username" required minLength={3} maxLength={64} autoComplete="off" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{tr('初始密码')}</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{tr('账号权限')}</span>
|
||||
<select name="role" defaultValue="user">
|
||||
{Object.entries(roleLabels).map(([v, label]) => (
|
||||
<option key={v} value={v}>
|
||||
{tr(label)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button className="primary" disabled={busy}>
|
||||
{tr('添加账号')}
|
||||
</button>
|
||||
</form>
|
||||
<p className="muted">
|
||||
{tr('管理员管理账号;普通用户可管理自己的数据;只读用户可查询数据并修改自己的登录密码。')}
|
||||
</p>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<h2>
|
||||
{tr('账号管理')} · {total}
|
||||
</h2>
|
||||
<p className="muted">
|
||||
{tr('修改权限或封禁后,该账号的登录会话与 MCP 授权会撤销,需要重新登录或授权。')}
|
||||
</p>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{tr('账号')}</th>
|
||||
<th>{tr('账号权限')}</th>
|
||||
<th>{tr('状态')}</th>
|
||||
<th>{tr('操作')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((account) => (
|
||||
<tr key={account.id}>
|
||||
<td>
|
||||
<strong>{account.username}</strong>
|
||||
{account.id === user.id && <small> · {tr('当前账号')}</small>}
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={account.username + ' ' + tr('账号权限')}
|
||||
value={account.role}
|
||||
disabled={busy || account.id === user.id}
|
||||
onChange={(e) => void change(account, { role: e.target.value })}
|
||||
>
|
||||
{Object.entries(roleLabels).map(([v, label]) => (
|
||||
<option key={v} value={v}>
|
||||
{tr(label)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{account.banned
|
||||
? tr('已封禁')
|
||||
: account.mustChangePassword
|
||||
? tr('待首次改密')
|
||||
: tr('正常')}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className={account.banned ? 'secondary' : 'danger'}
|
||||
disabled={busy || account.id === user.id}
|
||||
onClick={() => void change(account, { banned: !account.banned })}
|
||||
>
|
||||
{account.banned ? tr('解除封禁') : tr('封禁账号')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy || offset === 0}
|
||||
onClick={() => setOffset((v) => Math.max(0, v - 50))}
|
||||
>
|
||||
{tr('上一页')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy || offset + 50 >= total}
|
||||
onClick={() => setOffset((v) => v + 50)}
|
||||
>
|
||||
{tr('下一页')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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<HTMLFormElement>) {
|
||||
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<User>('/auth/me'));
|
||||
} catch (e) {
|
||||
report(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<main className="first-password">
|
||||
<section className="panel">
|
||||
<h1>{tr('首次登录,请修改密码')}</h1>
|
||||
<p className="muted">
|
||||
{tr('设置与初始密码不同的新密码后,即可进入系统。新密码至少 10 个字符,最多 72 字节。')}
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<label className="field">
|
||||
<span>{tr('当前密码')}</span>
|
||||
<input
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
maxLength={72}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{tr('新密码')}</span>
|
||||
<input
|
||||
name="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={72}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{tr('确认新密码')}</span>
|
||||
<input
|
||||
name="repeatPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={72}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary full" disabled={busy}>
|
||||
{tr('修改密码并进入')}
|
||||
</button>
|
||||
</form>
|
||||
<button className="text" disabled={busy} onClick={logout}>
|
||||
{tr('退出登录')}
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+35
-33
@@ -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<string, string> = {
|
||||
calendar: '收支日历',
|
||||
schedules: '定时计划',
|
||||
settings: '设置与备份',
|
||||
admin: '管理员后台',
|
||||
};
|
||||
const categories: Record<string, [string, string][]> = {
|
||||
account: [
|
||||
@@ -176,7 +178,6 @@ export default function App() {
|
||||
const authCheck = useRef<Promise<User> | null>(null);
|
||||
const [user, setUser] = useState<User | null>(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<User>('/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<Position[]>('/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<User>('/auth/me');
|
||||
@@ -829,13 +833,12 @@ export default function App() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const u = await api<User>('/auth/' + (register ? 'register' : 'login'), 'POST', {
|
||||
const u = await api<User>('/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() {
|
||||
<section className="auth-form">
|
||||
<div>
|
||||
<p className="eyebrow">{tr('开始你的资产之路')}</p>
|
||||
<h2>{register ? tr('创建账号') : tr('欢迎回来')}</h2>
|
||||
<p className="muted">
|
||||
{register ? tr('建立属于你的私人资产空间') : tr('登录以查看你的资产与负债')}
|
||||
</p>
|
||||
<h2>{tr('欢迎回来')}</h2>
|
||||
<p className="muted">{tr('登录以查看你的资产与负债')}</p>
|
||||
|
||||
<form onSubmit={auth} autoComplete="on">
|
||||
<Field label={tr('账号')}>
|
||||
@@ -932,14 +933,14 @@ export default function App() {
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete={register ? 'new-password' : 'current-password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
minLength={10}
|
||||
minLength={1}
|
||||
maxLength={72}
|
||||
placeholder={tr('至少 10 个字符,最多 72 字节')}
|
||||
placeholder={tr('输入密码')}
|
||||
/>
|
||||
</Field>
|
||||
{!register && (
|
||||
{
|
||||
<>
|
||||
<label className="check-line">
|
||||
<input
|
||||
@@ -951,24 +952,13 @@ export default function App() {
|
||||
</label>
|
||||
<p className="muted">{tr('密码由浏览器密码管理器保存,下次登录可自动填充。')}</p>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
<button className="primary full" disabled={busy}>
|
||||
{busy ? tr('正在处理…') : register ? tr('注册并进入') : tr('登录')}
|
||||
{busy ? tr('正在处理…') : tr('登录')}
|
||||
<ArrowUpRight size={18} />
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
{register ? tr('已有账号?') : tr('还没有账号?')}{' '}
|
||||
<button
|
||||
className="text"
|
||||
onClick={() => {
|
||||
setRegister(!register);
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
{register ? tr('登录') : tr('创建账号')}
|
||||
</button>
|
||||
</p>
|
||||
<p className="muted">{tr('账号由管理员创建,请联系管理员获取账号。')}</p>
|
||||
<p className="privacy">
|
||||
<ShieldCheck size={16} />
|
||||
{tr('个人数据按登录身份隔离')}
|
||||
@@ -977,6 +967,8 @@ export default function App() {
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
if (user.mustChangePassword)
|
||||
return <FirstPassword changed={setUser} logout={() => 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() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{page === 'account' && !p && !quickMode && (
|
||||
{user.role !== 'readonly' && page === 'account' && !p && !quickMode && (
|
||||
<button className="secondary" onClick={() => setModal({ kind: 'transfer' })}>
|
||||
{tr('转账')}
|
||||
</button>
|
||||
)}
|
||||
{['overview', 'account', 'asset', 'debt'].includes(page) &&
|
||||
{user.role !== 'readonly' &&
|
||||
['overview', 'account', 'asset', 'debt'].includes(page) &&
|
||||
!p &&
|
||||
!(page === 'account' && quickMode) &&
|
||||
newAction}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.role === 'readonly' && (
|
||||
<p className="muted" role="status">
|
||||
{tr('当前为只读账号,无法修改业务数据。')}
|
||||
</p>
|
||||
)}
|
||||
{loading && (
|
||||
<p role="status" className="muted">
|
||||
{tr('正在刷新数据…')}
|
||||
@@ -2128,6 +2129,7 @@ export default function App() {
|
||||
{page === 'calendar' && (
|
||||
<Calendar revealed={!!user.revealed} refreshVersion={calendarRefresh} />
|
||||
)}
|
||||
{page === 'admin' && user.role === 'admin' && <AdminPanel user={user} report={report} />}
|
||||
{page === 'settings' && (
|
||||
<>
|
||||
<nav className="settings-tabs" aria-label={tr('设置分类')}>
|
||||
|
||||
@@ -18,6 +18,9 @@ export async function api<T>(path: string, method = 'GET', data?: unknown): Prom
|
||||
return body;
|
||||
}
|
||||
export type User = {
|
||||
id?: string;
|
||||
role?: 'admin' | 'user' | 'readonly';
|
||||
mustChangePassword?: boolean;
|
||||
username: string;
|
||||
baseCurrency: string;
|
||||
hiddenMenus: string[];
|
||||
|
||||
@@ -622,5 +622,39 @@
|
||||
"密码由浏览器密码管理器保存,下次登录可自动填充。": "Your browser password manager saves the password for autofill next time.",
|
||||
"快速设置估值": "Quick valuation",
|
||||
"结束快速估值": "Finish quick valuation",
|
||||
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "Select an asset to enter its latest value; precious metals use automatic quotes."
|
||||
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "Select an asset to enter its latest value; precious metals use automatic quotes.",
|
||||
"管理员后台": "Administration",
|
||||
"管理员": "Administrator",
|
||||
"普通用户": "Standard user",
|
||||
"只读用户": "Read-only user",
|
||||
"添加账号": "Add user",
|
||||
"初始密码": "Initial password",
|
||||
"账号权限": "User role",
|
||||
"账号管理": "User management",
|
||||
"状态": "Status",
|
||||
"当前账号": "Current user",
|
||||
"已封禁": "Banned",
|
||||
"待首次改密": "Password change required",
|
||||
"正常": "Active",
|
||||
"封禁账号": "Ban user",
|
||||
"解除封禁": "Unban user",
|
||||
"输入密码": "Enter password",
|
||||
"账号由管理员创建,请联系管理员获取账号。": "Contact your administrator to obtain an account.",
|
||||
"新账号首次登录必须修改初始密码。": "New users must change their initial password on first login.",
|
||||
"管理员管理账号;普通用户可管理自己的数据;只读用户可查询数据并修改自己的登录密码。": "Administrators manage users. Standard users manage their own data. Read-only users can view data and change their own login password.",
|
||||
"修改权限或封禁后,该账号的登录会话与 MCP 授权会撤销,需要重新登录或授权。": "Changing a role or ban status revokes login sessions and MCP grants. The user must sign in or authorize again.",
|
||||
"首次登录,请修改密码": "Change your password on first login",
|
||||
"设置与初始密码不同的新密码后,即可进入系统。新密码至少 10 个字符,最多 72 字节。": "Set a password different from your initial password to continue. Use at least 10 characters and at most 72 bytes.",
|
||||
"修改密码并进入": "Change password and continue",
|
||||
"当前为只读账号,无法修改业务数据。": "This account is read-only and cannot change business data.",
|
||||
"公开注册已关闭,请联系管理员创建账号": "Public registration is closed. Contact your administrator.",
|
||||
"账号已被封禁": "This account has been banned.",
|
||||
"首次登录必须修改密码": "You must change your password on first login.",
|
||||
"只读账号不能修改数据": "Read-only accounts cannot change data.",
|
||||
"请设置与初始密码不同的新密码(至少 10 个字符)": "Choose a new password different from your initial password (at least 10 characters).",
|
||||
"需要已完成改密的管理员账号": "An administrator who has changed their initial password is required.",
|
||||
"不能修改自己的系统权限或封禁自己": "You cannot change your own role or ban yourself.",
|
||||
"必须保留至少一个可用管理员": "At least one active administrator must remain.",
|
||||
"管理员权限已变更": "Administrator permissions have changed.",
|
||||
"账号不存在": "User not found."
|
||||
}
|
||||
@@ -618,9 +618,43 @@
|
||||
"点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "點擊名稱切換折線;懸停或點擊圖表查看當天數據,方向鍵切換日期。",
|
||||
"当天趋势数据": "當天趨勢數據",
|
||||
"当天估值或汇率不完整,无法显示完整总额。": "當天估值或匯率不完整,無法顯示完整總額。",
|
||||
"记住账号密码": "記住帳號密碼",
|
||||
"密码由浏览器密码管理器保存,下次登录可自动填充。": "密碼由瀏覽器密碼管理器保存,下次登入可自動填入。",
|
||||
"快速设置估值": "快速設定估值",
|
||||
"记住账号密码": "記住賬號密碼",
|
||||
"密码由浏览器密码管理器保存,下次登录可自动填充。": "密碼由瀏覽器密碼管理器保存,下次登錄可自動填充。",
|
||||
"快速设置估值": "快速設置估值",
|
||||
"结束快速估值": "結束快速估值",
|
||||
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "點擊資產卡片填寫最新估值;貴金屬按報價自動估值。"
|
||||
"点击资产卡片填写最新估值;贵金属按报价自动估值。": "點擊資產卡片填寫最新估值;貴金屬按報價自動估值。",
|
||||
"管理员后台": "管理員後臺",
|
||||
"管理员": "管理員",
|
||||
"普通用户": "普通用戶",
|
||||
"只读用户": "只讀用戶",
|
||||
"添加账号": "添加賬號",
|
||||
"初始密码": "初始密碼",
|
||||
"账号权限": "賬號權限",
|
||||
"账号管理": "賬號管理",
|
||||
"状态": "狀態",
|
||||
"当前账号": "當前賬號",
|
||||
"已封禁": "已封禁",
|
||||
"待首次改密": "待首次改密",
|
||||
"正常": "正常",
|
||||
"封禁账号": "封禁賬號",
|
||||
"解除封禁": "解除封禁",
|
||||
"输入密码": "輸入密碼",
|
||||
"账号由管理员创建,请联系管理员获取账号。": "賬號由管理員創建,請聯繫管理員獲取賬號。",
|
||||
"新账号首次登录必须修改初始密码。": "新賬號首次登錄必須修改初始密碼。",
|
||||
"管理员管理账号;普通用户可管理自己的数据;只读用户可查询数据并修改自己的登录密码。": "管理員管理賬號;普通用戶可管理自己的數據;只讀用戶可查詢數據並修改自己的登錄密碼。",
|
||||
"修改权限或封禁后,该账号的登录会话与 MCP 授权会撤销,需要重新登录或授权。": "修改權限或封禁後,該賬號的登錄會話與 MCP 授權會撤銷,需要重新登錄或授權。",
|
||||
"首次登录,请修改密码": "首次登錄,請修改密碼",
|
||||
"设置与初始密码不同的新密码后,即可进入系统。新密码至少 10 个字符,最多 72 字节。": "設置與初始密碼不同的新密碼後,即可進入系統。新密碼至少 10 個字符,最多 72 字節。",
|
||||
"修改密码并进入": "修改密碼並進入",
|
||||
"当前为只读账号,无法修改业务数据。": "當前為只讀賬號,無法修改業務數據。",
|
||||
"公开注册已关闭,请联系管理员创建账号": "公開註冊已關閉,請聯繫管理員創建賬號",
|
||||
"账号已被封禁": "賬號已被封禁",
|
||||
"首次登录必须修改密码": "首次登錄必須修改密碼",
|
||||
"只读账号不能修改数据": "只讀賬號不能修改數據",
|
||||
"请设置与初始密码不同的新密码(至少 10 个字符)": "請設置與初始密碼不同的新密碼(至少 10 個字符)",
|
||||
"需要已完成改密的管理员账号": "需要已完成改密的管理員賬號",
|
||||
"不能修改自己的系统权限或封禁自己": "不能修改自己的系統權限或封禁自己",
|
||||
"必须保留至少一个可用管理员": "必須保留至少一個可用管理員",
|
||||
"管理员权限已变更": "管理員權限已變更",
|
||||
"账号不存在": "賬號不存在"
|
||||
}
|
||||
@@ -2567,3 +2567,28 @@ textarea,
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
/* Administrator accounts and mandatory first-login password change. */
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
.admin-create {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
.first-password {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.first-password > .panel {
|
||||
width: min(100%, 520px);
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.admin-create {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# 管理员账号与关闭注册
|
||||
|
||||
2026-10-05:新增 `User.role`、`User.banned`、`User.mustChangePassword`,迁移 `20261005130000_admin_accounts`。现有用户保留 `user`、未封禁、无需首次改密;初始化管理员和新创建账号强制首次改密。
|
||||
|
||||
配置位于 `apps/api/.env`:
|
||||
|
||||
```dotenv
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
```
|
||||
|
||||
初始化仅在数据库没有管理员时执行,不覆盖同名账号,不重置已存在管理员。账号名冲突时配置其他用户名再重启。公开注册接口返回 403,登录页面移除注册入口。初始密码允许 `admin`,新密码使用原有强密码校验,且不能与初始密码相同。未改密时只有个人身份、改密、活动和退出接口可访问;业务接口、后台和 MCP 授权被拒绝。
|
||||
|
||||
后台接口:`GET /api/admin/users?limit=50&offset=0`、`POST /api/admin/users`、`PATCH /api/admin/users/:id`。列表完整分页,仅返回账号身份、角色、状态和创建时间;不返回哈希、会话令牌或财务数据。角色为 `admin`、`user`、`readonly`。管理动作在事务内重查管理员权限;禁止修改自身角色或封禁自己,并保留可用管理员。
|
||||
|
||||
角色/封禁状态变化会删除该用户全部会话、撤销 MCP/PAT/OAuth 授权、取消待确认草稿和已批准待兑换授权。解封不会恢复旧令牌。密码修改也会撤销既有 MCP 授权。HTTP 与 MCP 后端都会检查封禁、首次改密和只读权限;只读用户可管理自己的登录密码及只读连接,无法提交财务写入、草稿或确认业务操作。管理员仍遵循原有财务数据用户隔离。
|
||||
|
||||
验证:前后端类型检查、网页生产构建、53 项单元测试,以及使用临时 MySQL 库和独立 API 的管理员与现有业务/MCP/OAuth 回归。当前数据库已应用新增迁移;本机网页验证默认管理员首次登录展示强制改密页,未替用户设置新密码。
|
||||
|
||||
运行隔离回归:在仓库根目录执行 `pnpm --filter @worthpath/api test:isolated`。测试库名使用随机 `wp_test_` 前缀,API 使用随机本机端口;完成或失败后清理自己创建的服务和数据库。管理员测试只在该隔离流程中运行,避免修改真实管理员。
|
||||
|
||||
已有迁移目录 `005_account_icons`、`006_navigation_transfers`、`007_notes_display` 排在初始迁移之前,因此原有 `migrate deploy` 从空库初始化会失败。本次未重命名历史迁移。隔离测试用当前 Prisma 模型建立临时库,再移除本次字段并执行新增 SQL 来验证增量迁移;现有库迁移正常。全新部署仍需要单独修复历史迁移顺序。
|
||||
@@ -16,7 +16,7 @@ Revision 保存按业务日期生效的绝对金额,每次金额更新新增
|
||||
|
||||
备份采用 version=3 ZIP(多个可读 JSON 文件),包含项目、历史、关系、币种、本位币、汇率和导入来源,不包含用户认证数据。首版导入只追加完整的新项目并重映射 ID;项目 ID 和 importedFromId 识别重复,用户 + importedFromId 有唯一索引,项目修改后仍拒绝原备份重复导入;不同 ID 的同名项目允许共存。汇率冲突拒绝;已有本位币不自动更改,空空间恢复备份本位币。完整验证后以 Serializable 事务写入,不修改已有项目。总览和导出使用数据库事务读取一致的数据视图。
|
||||
|
||||
页面:注册登录、总览、账户/资产/债务列表及详情与编辑、历史、本位币/汇率设置、备份与导入。空数据无演示金额。
|
||||
页面:登录、首次改密与管理员账号管理、总览、账户/资产/债务列表及详情与编辑、历史、本位币/汇率设置、备份与导入。空数据无演示金额。
|
||||
|
||||
数据库使用 Prisma 可追踪 SQL 迁移,部署仅 migrate deploy,禁止 db push/reset。扩展家庭共享时可以引入空间和成员权限,现阶段严格按用户隔离。
|
||||
|
||||
|
||||
Reference in new issue
Block a user