Fix MCP permissions and repayments; restrict deletion to default admin

This commit is contained in:
陈煜 committed 2026-10-05 20:26:17 +08:00
1 parent 35bd2e1828
commit 794274d31b
21 files changed
+702 -52

No files matched your search

+4 -2
View File
@@ -42,7 +42,7 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的
管理员登录并改密后,侧栏显示“管理员后台”,可添加账号、设置管理员/普通用户/只读用户权限、封禁和解除封禁。新建账号也需要首次改密。现有账号保留普通用户权限;管理员账号管理不会授予查看其他用户财务数据的能力。权限或封禁状态变化会撤销该用户全部登录会话和 MCP 授权;只读账号的业务写入、MCP 写入和草稿均由服务端拒绝,仍可改自己的登录密码、授权只读连接。详细验证见 [管理员功能验证](docs/admin-accounts.md)。 管理员登录并改密后,侧栏显示“管理员后台”,可添加账号、设置管理员/普通用户/只读用户权限、封禁和解除封禁。新建账号也需要首次改密。现有账号保留普通用户权限;管理员账号管理不会授予查看其他用户财务数据的能力。权限或封禁状态变化会撤销该用户全部登录会话和 MCP 授权;只读账号的业务写入、MCP 写入和草稿均由服务端拒绝,仍可改自己的登录密码、授权只读连接。详细验证见 [管理员功能验证](docs/admin-accounts.md)。
设置并登录另一位已完成改密的管理员后,可以在后台删除原 `admin`:输入原账号名称、验证当前管理员密码并确认。不能删除当前登录账号,且必须保留一位未封禁、已完成改密的管理员。删除会清理被删除账号的全部私人数据和授权,请先由该用户保存需要的备份;共享图标保留。仍有其他管理员时,重启不会重新创建默认账号。 仅允许删除默认 `admin`(账号名为 admin 且角色为管理员),其他管理员、普通用户和只读用户均禁止删除。先设置并登录另一位已完成改密的管理员,再输入 admin、验证当前管理员密码并确认。不能删除当前登录账号,且必须保留一位未封禁、已完成改密的管理员。删除会清理被删除账号的全部私人数据和授权,请先由该用户保存需要的备份;共享图标保留。仍有其他管理员时,重启不会重新创建默认账号。
金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额;已有错误记录可单独更正,转账及其之前的历史需通过新增余额调整修正,避免破坏双方一致性。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。 金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额;已有错误记录可单独更正,转账及其之前的历史需通过新增余额调整修正,避免破坏双方一致性。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。
@@ -92,7 +92,7 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json
## 账户分组、定时计划与收支日历 ## 账户分组、定时计划与收支日历
账户可自定义分组,并通过弹窗选择全部或分组账户;快速记账模式点击账户直接录入当前余额。资产账户允许透支,欠债显示负数,负债/支出显示红色。 账户可自定义分组,并通过弹窗选择全部或分组账户;快速记账模式点击账户直接录入当前余额。资产账户允许透支;余额(含信用卡溢缴存款)显示绿色,欠款和透支显示负数、红色;变化记录与账户选择器使用相同颜色。
“定时计划”支持支出及转账,打开账户页时按需执行,每批最多 20 项;仅在本应用内记账。新增“收支日历”默认今天,月历与当日明细在同页上下展示,按账户余额变化估算,排除内部转账、借贷本金及初始余额。 “定时计划”支持支出及转账,打开账户页时按需执行,每批最多 20 项;仅在本应用内记账。新增“收支日历”默认今天,月历与当日明细在同页上下展示,按账户余额变化估算,排除内部转账、借贷本金及初始余额。
@@ -148,3 +148,5 @@ git config remote.pushDefault github
MCP 同一连接、同一账目快照下的多个新增账号草稿可依次审阅确认;其他修改仍保留状态冲突检查。修复前已失效的草稿需只为尚未新增的账号重新生成,见 [多账号草稿确认修复](docs/fix-mcp-create-drafts-2026-10-04.md)。 MCP 同一连接、同一账目快照下的多个新增账号草稿可依次审阅确认;其他修改仍保留状态冲突检查。修复前已失效的草稿需只为尚未新增的账号重新生成,见 [多账号草稿确认修复](docs/fix-mcp-create-drafts-2026-10-04.md)。
独立资产支持现金分类和快速估值。登录页可记住账号并交由浏览器密码管理器保存密码。草稿弹窗支持全量分页、批量同意与取消,提交失败整批回滚。详见 [现金、草稿审阅与登录更新](docs/update-cash-drafts-login-2026-10-05.md)。 独立资产支持现金分类和快速估值。登录页可记住账号并交由浏览器密码管理器保存密码。草稿弹窗支持全量分页、批量同意与取消,提交失败整批回滚。详见 [现金、草稿审阅与登录更新](docs/update-cash-drafts-login-2026-10-05.md)。
已授权的 OAuth 与个人令牌可在“我的连接”修改只读、草稿或直接写入权限和隐藏账户权限,验证当前密码后生效;不延长授权期限,实际变更会取消该连接未确认草稿。MCP `movement_create(operation=repay)` 支持信用卡等负债账户,并保留真正还款类型及优惠、编辑和撤销语义。详见 [连接权限、还款与账号删除更新](docs/update-connections-repayment-users-2026-10-05.md)。
+1
View File
@@ -83,6 +83,7 @@ async function main() {
'test/mcp.test.ts', 'test/mcp.test.ts',
'test/oauth-duration.test.ts', 'test/oauth-duration.test.ts',
'test/database-migrations.test.ts', 'test/database-migrations.test.ts',
'test/connection-repayment-integration.test.ts',
]; ];
const result = spawnSync( const result = spawnSync(
process.execPath, process.execPath,
+2 -1
View File
@@ -135,7 +135,8 @@ export class AdminService {
throw new ForbiddenException('管理员权限已变更'); throw new ForbiddenException('管理员权限已变更');
const target = await tx.user.findUnique({ where: { id } }); const target = await tx.user.findUnique({ where: { id } });
if (!target) throw new NotFoundException('账号不存在'); if (!target) throw new NotFoundException('账号不存在');
if (target.role !== 'admin') throw new BadRequestException('仅支持删除管理员账号'); if (target.username !== 'admin' || target.role !== 'admin')
throw new ForbiddenException('仅允许删除默认 admin 账号,其他账号不可删除');
if (v.confirmationUsername !== target.username) if (v.confirmationUsername !== target.username)
throw new BadRequestException('确认账号名称不一致,请重新核对'); throw new BadRequestException('确认账号名称不一致,请重新核对');
if ( if (
+1 -1
View File
@@ -193,7 +193,7 @@ export class AgentCatalogue {
), ),
write( write(
'movement_create', 'movement_create',
'执行 transfer 转账、borrow 借入、lend 借出、collect 收款、repay 还款。amount 为本金,received 为到账/债务本金,fee 可负表示优惠;原币十进制字符串,双边事务和余额检查。', '执行 transfer 转账、borrow 借入、lend 借出、collect 收款、repay 还款。amount 为本金,received 为到账/债务本金,fee 可负表示优惠;原币十进制字符串,双边事务和余额检查。repay 的 sourceId 必须是资产账户,targetId 可以是借入债务或信用卡等负债账户;负债账户超额还款保留为存款。',
transferInput.safeExtend({ requestId: z.never().optional() }), transferInput.safeExtend({ requestId: z.never().optional() }),
(r, p) => transfers.create(r, p), (r, p) => transfers.create(r, p),
), ),
+51
View File
@@ -2,6 +2,8 @@ import {
Controller, Controller,
Get, Get,
Post, Post,
Patch,
NotFoundException,
Delete, Delete,
Req, Req,
Param, Param,
@@ -11,9 +13,11 @@ import {
ForbiddenException, ForbiddenException,
HttpException, HttpException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { compare } from 'bcryptjs'; import { compare } from 'bcryptjs';
import { Response } from 'express'; import { Response } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import { loginInput } from '../user-access';
import { Database } from '../database'; import { Database } from '../database';
import { AuthService, UserRequest } from '../auth'; import { AuthService, UserRequest } from '../auth';
import { AgentOAuth, urls, scopeInput, oauthDays } from './oauth'; import { AgentOAuth, urls, scopeInput, oauthDays } from './oauth';
@@ -96,6 +100,53 @@ export class AgentManagementController {
return { id: v.grant.id, token: v.tokens.access_token, expiresAt: v.grant.expiresAt }; return { id: v.grant.id, token: v.tokens.access_token, expiresAt: v.grant.expiresAt };
}); });
} }
@Patch('connections/:id') async permissions(
@Req() r: UserRequest,
@Param('id') id: string,
@Body() raw: unknown,
) {
z.string().uuid().parse(id);
const p = z
.object({ scopes: scopeInput, password: loginInput.shape.password })
.strict()
.parse(raw);
this.auth.limit(r);
const verified = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
if (!(await compare(p.password, verified.passwordHash)))
throw new ForbiddenException('密码错误');
return this.db.atomic(async () => {
await this.db.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`);
const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
if (user.banned || user.mustChangePassword || user.passwordHash !== verified.passwordHash)
throw new ForbiddenException('账号状态已变化,请重新登录');
if (
user.role === 'readonly' &&
p.scopes.some((s) => ['draft', 'write', 'hidden_write'].includes(s))
)
throw new ForbiddenException('只读账号只能授予查询权限');
// Refresh and edits lock the same grant before reading its permissions.
await this.db.$queryRaw(
Prisma.sql`SELECT id FROM AgentGrant WHERE id=${id} AND userId=${r.userId} FOR UPDATE`,
);
const grant = await this.db.agentGrant.findFirst({ where: { id, userId: r.userId } });
const expiry = grant?.clientId ? grant.refreshExpiresAt : grant?.expiresAt;
if (!grant || grant.revokedAt || (expiry && expiry <= new Date()))
throw new NotFoundException('有效连接不存在');
const previous = grant.scopes as string[];
if (
previous.length === p.scopes.length &&
previous.every((s) => (p.scopes as string[]).includes(s))
)
return { ok: true };
await this.db.agentGrant.update({ where: { id }, data: { scopes: p.scopes } });
// Permission edits never execute old drafts under a newly granted privilege.
await this.db.agentOperation.updateMany({
where: { grantId: id, userId: r.userId, status: 'pending' },
data: { status: 'cancelled', completedAt: new Date() },
});
return { ok: true };
});
}
@Delete('connections/:id') async revoke(@Req() r: UserRequest, @Param('id') id: string) { @Delete('connections/:id') async revoke(@Req() r: UserRequest, @Param('id') id: string) {
z.string().uuid().parse(id); z.string().uuid().parse(id);
await this.db.agentGrant.updateMany({ await this.db.agentGrant.updateMany({
+4
View File
@@ -1,6 +1,7 @@
import { networkConfig } from '../network'; import { networkConfig } from '../network';
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
import { randomBytes, randomUUID, createHash } from 'node:crypto'; import { randomBytes, randomUUID, createHash } from 'node:crypto';
import { Prisma } from '@prisma/client';
import { Response } from 'express'; import { Response } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import { Database } from '../database'; import { Database } from '../database';
@@ -324,6 +325,9 @@ export class AgentOAuth implements OAuthServerProvider {
) { ) {
this.resource(resource); this.resource(resource);
return this.db.atomic(async () => { return this.db.atomic(async () => {
await this.db.$queryRaw(
Prisma.sql`SELECT id FROM AgentGrant WHERE refreshDigest=${digest(token)} FOR UPDATE`,
);
const row = await this.db.agentGrant.findUnique({ where: { refreshDigest: digest(token) } }); const row = await this.db.agentGrant.findUnique({ where: { refreshDigest: digest(token) } });
if ( if (
!row || !row ||
+10
View File
@@ -1,3 +1,4 @@
import { scopeInput } from './mcp/oauth';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { metalConfig, metalHoldingInput } from './metals'; import { metalConfig, metalHoldingInput } from './metals';
@@ -38,6 +39,9 @@ export function setupOpenApi(app: INestApplication) {
'PATCH /api/admin/users/{id}': adminUpdateInput, 'PATCH /api/admin/users/{id}': adminUpdateInput,
'DELETE /api/admin/users/{id}': adminDeleteInput, 'DELETE /api/admin/users/{id}': adminDeleteInput,
'PATCH /api/auth/credentials': credentialChange, 'PATCH /api/auth/credentials': credentialChange,
'PATCH /api/agent/connections/{id}': z
.object({ scopes: scopeInput, password: loginInput.shape.password })
.strict(),
'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(), 'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(),
'POST /api/positions': positionInput, 'POST /api/positions': positionInput,
'PATCH /api/positions/{id}': positionMeta, 'PATCH /api/positions/{id}': positionMeta,
@@ -101,6 +105,12 @@ export function setupOpenApi(app: INestApplication) {
description: '{ items, nextCursor, revealed };金额为绝对余额,before 为真实前序余额', description: '{ items, nextCursor, revealed };金额为绝对余额,before 为真实前序余额',
}; };
} }
if (method === 'patch' && path === '/api/agent/connections/{id}')
operation.description =
'验证当前密码后修改自己的有效 OAuth/PAT 连接权限;实际权限变更取消未确认草稿,不延长授权期限。只读用户不能授予写入。';
if (['post', 'put'].includes(method) && path.startsWith('/api/transfers'))
operation.description =
'repay 从资产账户向借入债务或负债账户还款;负债账户超额还款保存为溢缴存款。amount/received 为原币本金,fee 负数表示优惠。同币种本金一致,双边金额与历史原子更新。';
if (method === 'get' && path === '/api/trend') { if (method === 'get' && path === '/api/trend') {
operation.parameters = [ operation.parameters = [
...['from', 'to'].map((name) => ({ ...['from', 'to'].map((name) => ({
+6 -4
View File
@@ -183,10 +183,12 @@ export async function executeMovement(
(v.operation !== 'transfer' && source.side !== 'asset') || (v.operation !== 'transfer' && source.side !== 'asset') ||
(v.operation === 'transfer' (v.operation === 'transfer'
? target.kind !== 'account' ? target.kind !== 'account'
: target.kind !== 'debt' || : !(
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset')) (target.kind === 'debt' || (v.operation === 'repay' && target.kind === 'account')) &&
target.side === (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset')
))
) )
throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务'); throw new BadRequestException('请选择资产账户及对应债务;还款也可选择负债账户');
if (accounts.some((p) => p.revisions[0] && +p.revisions[0].effectiveDate > +when)) if (accounts.some((p) => p.revisions[0] && +p.revisions[0].effectiveDate > +when))
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账'); throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received)) if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
@@ -235,7 +237,7 @@ export async function executeMovement(
notes: v.notes, notes: v.notes,
}, },
}); });
if (v.operation !== 'transfer') if (v.operation !== 'transfer' && target.kind === 'debt')
await tx.positionLink.upsert({ await tx.positionLink.upsert({
where: { sourceId_targetId: { sourceId: target.id, targetId: source.id } }, where: { sourceId_targetId: { sourceId: target.id, targetId: source.id } },
create: { sourceId: target.id, targetId: source.id }, create: { sourceId: target.id, targetId: source.id },
@@ -0,0 +1,362 @@
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID, randomBytes } from 'node:crypto';
import { hash } from 'bcryptjs';
import { Database } from '../src/database';
import { AgentOAuth, urls } from '../src/mcp/oauth';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { today } from '../src/validation';
test(
'connection permission edits, OAuth refresh, account repayment drafts and protecting non-default user accounts',
{
skip: process.env.TEST_ISOLATED !== 'true',
},
async () => {
const db = new Database(),
oauth = new AgentOAuth(db);
const ids: string[] = [],
clients: Client[] = [];
let clientId = '';
const password = randomBytes(20).toString('hex');
const base = process.env.TEST_API_URL!;
async function web(cookie: string, path: string, method = 'GET', body?: unknown) {
const r = await fetch(base + path, {
method,
headers: {
Origin: process.env.WEB_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,
};
}
async function user(role: 'admin' | 'user' | 'readonly') {
const u = await db.user.create({
data: {
username: 'update_access_' + randomUUID(),
role,
passwordHash: await hash(password, 10),
},
});
ids.push(u.id);
const login = await web('', '/auth/login', 'POST', { username: u.username, password });
assert.equal(login.status, 201);
return { ...u, cookie: login.cookie };
}
async function connect(token: string) {
const client = new Client({ name: 'Update regression', version: '1' });
clients.push(client);
await client.connect(
new StreamableHTTPClientTransport(urls().resource, {
requestInit: { headers: { Authorization: 'Bearer ' + token } },
}),
);
assert.ok((await client.listTools()).tools.some((t) => t.name === 'movement_create'));
return client;
}
async function tool(c: Client, name: string, args: any = {}) {
const r: any = await c.callTool({ name, arguments: args });
assert.ok(!r.isError, JSON.stringify(r));
return r.structuredContent.data;
}
async function draft(c: Client, name: string, args: any) {
return tool(c, name, {
...args,
expectedState: (await tool(c, 'state_get')).state,
idempotencyKey: randomUUID(),
});
}
const position = {
kind: 'account',
side: 'asset',
name: 'payer',
category: 'bank',
currency: 'CNY',
amount: '100',
date: today(),
notes: '',
};
try {
const admin = await user('admin'),
owner = await user('user'),
readonly = await user('readonly');
const pat = await web(owner.cookie, '/agent/tokens', 'POST', {
name: 'draft grant',
scopes: ['read', 'draft'],
days: 1,
password,
});
assert.equal(pat.status, 201);
const sdk = await connect(pat.data.token);
const pending = await draft(sdk, 'position_create', position);
assert.equal(pending.status, 'pending');
assert.equal(
(
await web(owner.cookie, '/agent/connections/' + pat.data.id, 'PATCH', {
scopes: ['read', 'draft'],
password,
})
).status,
200,
);
assert.equal(
(await tool(sdk, 'operation_get', { operationId: pending.operationId })).status,
'pending',
);
const path = '/agent/connections/' + pat.data.id;
assert.equal(
(await web(admin.cookie, path, 'PATCH', { scopes: ['read', 'write'], password })).status,
404,
);
assert.equal(
(await web(owner.cookie, path, 'PATCH', { scopes: ['read', 'write'], password: 'wrong' }))
.status,
403,
);
assert.equal(
(await web(owner.cookie, path, 'PATCH', { scopes: ['read', 'draft', 'write'], password }))
.status,
400,
);
const before = await db.agentGrant.findUniqueOrThrow({ where: { id: pat.data.id } });
assert.equal(
(await web(owner.cookie, path, 'PATCH', { scopes: ['read', 'write'], password })).status,
200,
);
assert.equal((await tool(sdk, 'connection_info')).permission, 'write');
assert.equal(
(await tool(sdk, 'operation_get', { operationId: pending.operationId })).status,
'cancelled',
);
assert.equal(
(
await db.agentGrant.findUniqueOrThrow({ where: { id: before.id } })
).expiresAt?.toISOString(),
before.expiresAt?.toISOString(),
);
assert.equal(await db.position.count({ where: { userId: owner.id } }), 0);
assert.equal(
(await web(owner.cookie, path, 'PATCH', { scopes: ['read', 'draft'], password })).status,
200,
);
const source = await web(owner.cookie, '/positions', 'POST', position);
const target = await web(owner.cookie, '/positions', 'POST', {
...position,
side: 'liability',
name: 'credit',
category: 'credit_card',
amount: '80',
});
assert.equal(source.status, 201);
assert.equal(target.status, 201);
const repayment = {
operation: 'repay',
sourceId: source.data.id,
targetId: target.data.id,
amount: '50',
received: '50',
fee: '-2',
date: today(),
notes: '',
};
const pay = await draft(sdk, 'movement_create', repayment);
assert.equal(pay.status, 'pending');
assert.equal((await tool(sdk, 'position_get', { id: source.data.id })).amount, '100');
const approved = await web(owner.cookie, '/agent/operations/confirm-batch', 'POST', {
approve: true,
operationIds: [pay.operationId],
});
assert.equal(approved.status, 201, JSON.stringify(approved.data));
const applied = await tool(sdk, 'operation_get', { operationId: pay.operationId });
assert.equal(applied.status, 'completed');
assert.equal((await tool(sdk, 'position_get', { id: source.data.id })).amount, '52');
assert.equal((await tool(sdk, 'position_get', { id: target.data.id })).amount, '30');
const record = await db.transfer.findUniqueOrThrow({ where: { id: applied.result.id } });
assert.equal(record.operation, 'repay');
assert.equal(await db.positionLink.count({ where: { sourceId: target.data.id } }), 0);
assert.equal(
(
await web(owner.cookie, '/transfers/' + record.id, 'PUT', {
...repayment,
amount: '90',
received: '90',
fee: '0',
})
).status,
200,
);
assert.equal((await tool(sdk, 'position_get', { id: target.data.id })).amount, '-10');
assert.equal((await web(owner.cookie, '/transfers/' + record.id, 'DELETE')).status, 200);
assert.equal((await tool(sdk, 'position_get', { id: source.data.id })).amount, '100');
assert.equal((await tool(sdk, 'position_get', { id: target.data.id })).amount, '80');
assert.equal(
(
await web(owner.cookie, '/transfers', 'POST', {
...repayment,
sourceId: target.data.id,
targetId: source.data.id,
})
).status,
400,
);
assert.equal((await web(admin.cookie, '/transfers', 'POST', repayment)).status, 400);
const client = await oauth.clientsStore.registerClient({
client_name: 'Permission regression',
redirect_uris: ['http://127.0.0.1:47891/callback'],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
});
clientId = client.client_id;
const issued = await db.atomic(() =>
oauth.issue(owner.id, 'OAuth regression', ['read'], 1 / 24, clientId, 7),
);
const oauthPath = '/agent/connections/' + issued.grant.id;
const rights = ['read', 'write', 'hidden_read', 'hidden_write'];
assert.equal(
(await web(owner.cookie, oauthPath, 'PATCH', { scopes: rights, password })).status,
200,
);
assert.deepEqual((await oauth.verifyAccessToken(issued.tokens.access_token)).scopes, rights);
const refreshed = await oauth.exchangeRefreshToken(
client,
issued.tokens.refresh_token!,
undefined,
urls().resource,
);
assert.deepEqual((await oauth.verifyAccessToken(refreshed.access_token)).scopes, rights);
assert.equal(
(
await db.agentGrant.findUniqueOrThrow({ where: { id: issued.grant.id } })
).refreshExpiresAt?.toISOString(),
issued.grant.refreshExpiresAt?.toISOString(),
);
await db.agentGrant.update({
where: { id: issued.grant.id },
data: { expiresAt: new Date(0) },
});
assert.equal(
(await web(owner.cookie, oauthPath, 'PATCH', { scopes: ['read'], password })).status,
200,
);
const renewed = await oauth.exchangeRefreshToken(
client,
refreshed.refresh_token!,
undefined,
urls().resource,
);
assert.deepEqual((await oauth.verifyAccessToken(renewed.access_token)).scopes, ['read']);
await assert.rejects(
oauth.exchangeRefreshToken(
client,
renewed.refresh_token!,
['read', 'write'],
urls().resource,
),
);
const concurrent = await Promise.all([
web(owner.cookie, oauthPath, 'PATCH', { scopes: rights, password }),
oauth.exchangeRefreshToken(client, renewed.refresh_token!, undefined, urls().resource),
]);
assert.equal(concurrent[0].status, 200);
assert.deepEqual((await oauth.verifyAccessToken(concurrent[1].access_token)).scopes, rights);
await db.agentGrant.update({
where: { id: issued.grant.id },
data: { refreshExpiresAt: new Date(0) },
});
assert.equal(
(await web(owner.cookie, oauthPath, 'PATCH', { scopes: ['read'], password })).status,
404,
);
const openapi = await web(owner.cookie, '/openapi.json');
assert.ok(
openapi.data.paths['/api/agent/connections/{id}'].patch.requestBody.content[
'application/json'
].schema.properties.scopes,
);
const ro = await web(readonly.cookie, '/agent/tokens', 'POST', {
name: 'readonly',
scopes: ['read'],
days: 1,
password,
});
assert.equal(ro.status, 201);
assert.equal(
(
await web(readonly.cookie, '/agent/connections/' + ro.data.id, 'PATCH', {
scopes: ['read', 'write'],
password,
})
).status,
403,
);
assert.equal(
(
await web(readonly.cookie, '/agent/connections/' + ro.data.id, 'PATCH', {
scopes: ['read', 'hidden_read'],
password,
})
).status,
200,
);
await web(owner.cookie, path, 'DELETE');
assert.equal(
(await web(owner.cookie, path, 'PATCH', { scopes: ['read'], password })).status,
404,
);
await assert.rejects(oauth.verifyAccessToken(pat.data.token));
for (const targetUser of [owner, readonly, await user('admin')]) {
const removePath = '/admin/users/' + targetUser.id;
const body = { confirmationUsername: targetUser.username, currentPassword: password };
assert.equal((await web(readonly.cookie, removePath, 'DELETE', body)).status, 403);
assert.equal(
(
await web(admin.cookie, removePath, 'DELETE', {
...body,
confirmationUsername: 'wrong',
})
).status,
403,
);
assert.equal(
(await web(admin.cookie, removePath, 'DELETE', { ...body, currentPassword: 'wrong' }))
.status,
403,
);
assert.equal((await web(admin.cookie, removePath, 'DELETE', body)).status, 403);
assert.ok(await db.user.findUnique({ where: { id: targetUser.id } }));
if (targetUser.id === owner.id)
assert.equal(await db.position.count({ where: { userId: targetUser.id } }), 2);
if (targetUser.id === owner.id)
assert.equal(await db.agentGrant.count({ where: { userId: targetUser.id } }), 2);
assert.equal((await web(targetUser.cookie, '/auth/me')).status, 200);
}
assert.equal(
(
await web(admin.cookie, '/admin/users/' + admin.id, 'DELETE', {
confirmationUsername: admin.username,
currentPassword: password,
})
).status,
403,
);
} finally {
for (const c of clients) await c.close().catch(() => {});
await db.user.deleteMany({ where: { id: { in: ids } } });
if (clientId) await db.agentClient.deleteMany({ where: { id: clientId } });
await db.$disconnect();
}
},
);
+18 -2
View File
@@ -6,6 +6,8 @@ test('HTTP resources, web links and client redirects work beyond loopback in dev
const before = { ...process.env }; const before = { ...process.env };
try { try {
process.env.NODE_ENV = 'development'; process.env.NODE_ENV = 'development';
process.env.NETWORK_ALLOW_HTTP = 'true';
process.env.NETWORK_ALLOW_HTTP_REDIRECTS = 'true';
process.env.MCP_PUBLIC_URL = 'http://192.0.2.10:3100/mcp'; process.env.MCP_PUBLIC_URL = 'http://192.0.2.10:3100/mcp';
process.env.MCP_WEB_URL = 'http://192.0.2.10:5173'; process.env.MCP_WEB_URL = 'http://192.0.2.10:5173';
assert.equal(urls().issuer.href, 'http://192.0.2.10:3100/'); assert.equal(urls().issuer.href, 'http://192.0.2.10:3100/');
@@ -28,7 +30,13 @@ test('HTTP resources, web links and client redirects work beyond loopback in dev
}), }),
); );
} finally { } finally {
for (const k of ['NODE_ENV', 'MCP_PUBLIC_URL', 'MCP_WEB_URL']) { for (const k of [
'NODE_ENV',
'MCP_PUBLIC_URL',
'MCP_WEB_URL',
'NETWORK_ALLOW_HTTP',
'NETWORK_ALLOW_HTTP_REDIRECTS',
]) {
if (before[k] === undefined) delete process.env[k]; if (before[k] === undefined) delete process.env[k];
else process.env[k] = before[k]; else process.env[k] = before[k];
} }
@@ -39,6 +47,8 @@ test('production requires HTTPS for configured endpoints and rejects non-loopbac
const before = { ...process.env }; const before = { ...process.env };
try { try {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
process.env.NETWORK_ALLOW_HTTP = 'false';
process.env.NETWORK_ALLOW_HTTP_REDIRECTS = 'false';
process.env.MCP_PUBLIC_URL = 'http://192.0.2.10:3100/mcp'; process.env.MCP_PUBLIC_URL = 'http://192.0.2.10:3100/mcp';
process.env.MCP_WEB_URL = 'https://worthpath.example'; process.env.MCP_WEB_URL = 'https://worthpath.example';
assert.throws(() => urls(), /HTTPS/); assert.throws(() => urls(), /HTTPS/);
@@ -61,7 +71,13 @@ test('production requires HTTPS for configured endpoints and rejects non-loopbac
token_endpoint_auth_method: 'none', token_endpoint_auth_method: 'none',
}); });
} finally { } finally {
for (const k of ['NODE_ENV', 'MCP_PUBLIC_URL', 'MCP_WEB_URL']) { for (const k of [
'NODE_ENV',
'MCP_PUBLIC_URL',
'MCP_WEB_URL',
'NETWORK_ALLOW_HTTP',
'NETWORK_ALLOW_HTTP_REDIRECTS',
]) {
if (before[k] === undefined) delete process.env[k]; if (before[k] === undefined) delete process.env[k];
else process.env[k] = before[k]; else process.env[k] = before[k];
} }
+5 -1
View File
@@ -85,7 +85,11 @@ export function AccountPicker({
{p.iconId && <img src={iconUrl(p.iconId)} alt="" />} {p.iconId && <img src={iconUrl(p.iconId)} alt="" />}
<span> <span>
{p.name} {p.name}
<small>{money(positionAmount(p), p.currency)}</small> <small
className={positionAmount(p).startsWith('-') ? 'danger-text' : 'income-text'}
>
{money(positionAmount(p), p.currency)}
</small>
</span> </span>
{p.id === value && <span>✓</span>} {p.id === value && <span>✓</span>}
</button> </button>
+19 -15
View File
@@ -183,18 +183,20 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
> >
{account.banned ? tr('解除封禁') : tr('封禁账号')} {account.banned ? tr('解除封禁') : tr('封禁账号')}
</button> </button>
{account.role === 'admin' && account.id !== user.id && ( {account.username === 'admin' &&
<button account.role === 'admin' &&
className="danger" account.id !== user.id && (
disabled={busy} <button
onClick={() => { className="danger"
setDeleting(account); disabled={busy}
setConfirmation(''); onClick={() => {
}} setDeleting(account);
> setConfirmation('');
{tr('删除管理员')} }}
</button> >
)} {tr('删除默认 admin')}
</button>
)}
</td> </td>
</tr> </tr>
))} ))}
@@ -220,9 +222,9 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
</section> </section>
{deleting && ( {deleting && (
<div className="veil"> <div className="veil">
<section className="modal" role="dialog" aria-modal="true" aria-label={tr('删除管理员')}> <section className="modal" role="dialog" aria-modal="true" aria-label={tr('删除账号')}>
<h2> <h2>
{tr('删除管理员')} · {deleting.username} {tr('删除账号')} · {deleting.username}
</h2> </h2>
<p className="danger-text"> <p className="danger-text">
{tr( {tr(
@@ -230,7 +232,9 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
)} )}
</p> </p>
<p className="muted"> <p className="muted">
{tr('必须保留另一位已完成改密且未封禁的管理员。不能删除当前登录账号。')} {tr(
'仅允许删除默认 admin 账号;其他账号禁止删除。必须保留另一位已完成改密且未封禁的管理员,不能删除当前登录账号。',
)}
</p> </p>
<form onSubmit={remove}> <form onSubmit={remove}>
<label className="field"> <label className="field">
+140 -14
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from './api'; import { api } from './api';
import { AgentDrafts } from './AgentDrafts'; import { AgentDrafts, ReviewDialog } from './AgentDrafts';
import { toolLabel, statusLabel, permissionLabel } from './agent-display'; import { toolLabel, statusLabel, permissionLabel } from './agent-display';
import { showToast, useToast } from './Toast'; import { showToast, useToast } from './Toast';
@@ -118,6 +118,10 @@ function codexSetupPrompt(url: string, level: string, hiddenRead = false, hidden
const connectionExpiry = (g: Connection) => (g.clientId ? g.refreshExpiresAt : g.expiresAt); const connectionExpiry = (g: Connection) => (g.clientId ? g.refreshExpiresAt : g.expiresAt);
export function AgentConnections({ changed }: { changed?: () => void }) { export function AgentConnections({ changed }: { changed?: () => void }) {
const [editing, setEditing] = useState<Connection | null>(null);
const [editPermission, setEditPermission] = useState('read');
const [editHiddenRead, setEditHiddenRead] = useState(false);
const [editHiddenWrite, setEditHiddenWrite] = useState(false);
const [review, setReview] = useState<string | null>(null); const [review, setReview] = useState<string | null>(null);
const [data, setData] = useState<Management | null>(null), const [data, setData] = useState<Management | null>(null),
[view, setView] = useState('connect'), [view, setView] = useState('connect'),
@@ -322,7 +326,9 @@ export function AgentConnections({ changed }: { changed?: () => void }) {
<> <>
<section className="agent-card"> <section className="agent-card">
<h3>已授权连接</h3> <h3>已授权连接</h3>
<p className="muted">最近 100 条 · 撤销后助手将无法继续访问。</p> <p className="muted">
最近 100 条 · 可修改权限,OAuth 也支持直接写入;撤销后助手无法继续访问。
</p>
{!data.grants.length && ( {!data.grants.length && (
<div className="agent-empty"> <div className="agent-empty">
<h4>还没有连接</h4> <h4>还没有连接</h4>
@@ -357,18 +363,40 @@ export function AgentConnections({ changed }: { changed?: () => void }) {
</small> </small>
</div> </div>
{!g.revokedAt && ( {!g.revokedAt && (
<button <div className="actions">
className="secondary" {active.some((a) => a.id === g.id) && (
disabled={busy} <button
onClick={() => className="secondary"
void act(async () => { disabled={busy}
await api('/agent/connections/' + g.id, 'DELETE'); onClick={() => {
showToast('连接已撤销', 'success'); setEditing(g);
}) setEditPermission(
} g.scopes.includes('write')
> ? 'write'
撤销连接 : g.scopes.includes('draft')
</button> ? 'draft'
: 'read',
);
setEditHiddenRead(g.scopes.includes('hidden_read'));
setEditHiddenWrite(g.scopes.includes('hidden_write'));
}}
>
修改权限
</button>
)}
<button
className="secondary"
disabled={busy}
onClick={() =>
void act(async () => {
await api('/agent/connections/' + g.id, 'DELETE');
showToast('连接已撤销', 'success');
})
}
>
撤销连接
</button>
</div>
)} )}
</div> </div>
))} ))}
@@ -591,6 +619,104 @@ export function AgentConnections({ changed }: { changed?: () => void }) {
</section> </section>
</> </>
)} )}
{editing && (
<ReviewDialog title="修改连接权限" close={() => setEditing(null)} busy={busy}>
<p>
<strong>{editing.name}</strong> · {editing.clientId ? 'OAuth' : '个人令牌'}
</p>
<p className="muted">
保存后权限立即生效,授权期限保持不变。修改权限会取消该连接未确认的草稿,需要重新创建。
</p>
<form
className="agent-form"
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
void act(async () => {
await api('/agent/connections/' + editing.id, 'PATCH', {
password: f.get('password'),
scopes: [
'read',
...(editPermission === 'read' ? [] : [editPermission]),
...(editHiddenRead ? ['hidden_read'] : []),
...(editHiddenRead && editHiddenWrite && editPermission !== 'read'
? ['hidden_write']
: []),
],
});
setEditing(null);
showToast('连接权限已更新', 'success');
});
}}
>
<label>
连接权限
<select
value={editPermission}
onChange={(e) => {
setEditPermission(e.target.value);
if (e.target.value === 'read') setEditHiddenWrite(false);
}}
>
<option value="read">只读查询</option>
<option value="draft">草稿修改</option>
<option value="write">直接写入</option>
</select>
</label>
{editPermission === 'write' && (
<p className="danger-text">
直接写入会立即执行普通修改,无需逐次网页确认。请仅授予你信任的助手。
</p>
)}
<fieldset className="agent-scope-options">
<legend>隐藏账户权限</legend>
<label className="check-line">
<input
type="checkbox"
checked={editHiddenRead}
onChange={(e) => {
setEditHiddenRead(e.target.checked);
if (!e.target.checked) setEditHiddenWrite(false);
}}
/>
允许读取隐藏账户
</label>
<label className="check-line">
<input
type="checkbox"
checked={editHiddenWrite}
disabled={!editHiddenRead || editPermission === 'read'}
onChange={(e) => setEditHiddenWrite(e.target.checked)}
/>
允许修改隐藏账户
</label>
</fieldset>
<label>
验证当前密码
<input
name="password"
type="password"
required
maxLength={72}
autoComplete="current-password"
/>
</label>
<div className="actions">
<button
type="button"
className="secondary"
disabled={busy}
onClick={() => setEditing(null)}
>
取消
</button>
<button className="primary" disabled={busy}>
保存权限
</button>
</div>
</form>
</ReviewDialog>
)}
{review !== null && ( {review !== null && (
<AgentDrafts <AgentDrafts
initialId={review === 'all' ? undefined : review} initialId={review === 'all' ? undefined : review}
+4 -2
View File
@@ -9,10 +9,12 @@ export function ReviewDialog({
children, children,
close, close,
busy = false, busy = false,
title = '审阅草稿',
}: { }: {
children: ReactNode; children: ReactNode;
close: () => void; close: () => void;
busy?: boolean; busy?: boolean;
title?: string;
}) { }) {
const ref = useRef<HTMLElement>(null); const ref = useRef<HTMLElement>(null);
useEffect(() => { useEffect(() => {
@@ -38,7 +40,7 @@ export function ReviewDialog({
className="modal draft-dialog" className="modal draft-dialog"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="审阅草稿" aria-label={title}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Escape' && !busy) { if (e.key === 'Escape' && !busy) {
e.preventDefault(); e.preventDefault();
@@ -73,7 +75,7 @@ export function ReviewDialog({
}} }}
> >
<header> <header>
<h2>审阅草稿</h2> <h2>{title}</h2>
<button className="icon" aria-label="关闭" disabled={busy} onClick={close}> <button className="icon" aria-label="关闭" disabled={busy} onClick={close}>
× ×
</button> </button>
+14 -6
View File
@@ -1260,7 +1260,7 @@ export default function App() {
</div> </div>
<strong <strong
className={ className={
cls === 'debt' || value.startsWith('-') ? 'danger-text' : undefined cls === 'debt' || value.startsWith('-') ? 'danger-text' : 'income-text'
} }
> >
{money(value, user.baseCurrency)} {money(value, user.baseCurrency)}
@@ -1468,7 +1468,9 @@ export default function App() {
)} )}
{p.archived ? tr(' · 已归档') : ''} {p.archived ? tr(' · 已归档') : ''}
</span> </span>
<h2 className={positionAmount(p).startsWith('-') ? 'danger-text' : undefined}> <h2
className={positionAmount(p).startsWith('-') ? 'danger-text' : 'income-text'}
>
{p.valuationAvailable === false {p.valuationAvailable === false
? tr('待估值') ? tr('待估值')
: money(positionAmount(p), p.currency)} : money(positionAmount(p), p.currency)}
@@ -1922,7 +1924,7 @@ export default function App() {
</p> </p>
<strong <strong
className={ className={
positionAmount(p).startsWith('-') ? 'danger-text' : undefined positionAmount(p).startsWith('-') ? 'danger-text' : 'income-text'
} }
> >
{p.valuationAvailable === false {p.valuationAvailable === false
@@ -3474,17 +3476,23 @@ function HistoryTable({
</small> </small>
</td> </td>
<td <td
className={positionAmount(h, h.before).startsWith('-') ? 'danger-text' : undefined} className={
positionAmount(h, h.before).startsWith('-') ? 'danger-text' : 'income-text'
}
> >
{money(positionAmount(h, h.before), h.currency)} {money(positionAmount(h, h.before), h.currency)}
</td> </td>
<td <td
className={positionAmount(h, h.after).startsWith('-') ? 'danger-text' : undefined} className={
positionAmount(h, h.after).startsWith('-') ? 'danger-text' : 'income-text'
}
> >
{money(positionAmount(h, h.after), h.currency)} {money(positionAmount(h, h.after), h.currency)}
</td> </td>
<td <td
className={positionAmount(h, h.delta).startsWith('-') ? 'danger-text' : undefined} className={
positionAmount(h, h.delta).startsWith('-') ? 'danger-text' : 'income-text'
}
> >
{money(positionAmount(h, h.delta), h.currency)} {money(positionAmount(h, h.delta), h.currency)}
</td> </td>
+1 -1
View File
@@ -137,7 +137,7 @@ export function QuickRepayment({
const source = choices.find((p) => p.id === sourceId); const source = choices.find((p) => p.id === sourceId);
return ( return (
<section> <section>
<p> <p className={positionAmount(target).startsWith('-') ? 'danger-text' : 'income-text'}>
{tr('还款账户')}:{target.name} · {money(positionAmount(target), target.currency)} {tr('还款账户')}:{target.name} · {money(positionAmount(target), target.currency)}
</p> </p>
<p className="muted"> <p className="muted">
+1 -1
View File
@@ -1276,7 +1276,7 @@ footer {
color: #c73542 !important; color: #c73542 !important;
} }
.income-text { .income-text {
color: #1b7855; color: #1b7855 !important;
} }
.calendar-grid { .calendar-grid {
display: grid; display: grid;
+3 -1
View File
@@ -13,7 +13,7 @@ ADMIN_PASSWORD=admin
后台接口:`GET /api/admin/users?limit=50&offset=0`、`POST /api/admin/users`、`PATCH /api/admin/users/:id`。列表完整分页,仅返回账号身份、角色、状态和创建时间;不返回哈希、会话令牌或财务数据。角色为 `admin`、`user`、`readonly`。管理动作在事务内重查管理员权限;禁止修改自身角色或封禁自己,并保留可用管理员。 后台接口:`GET /api/admin/users?limit=50&offset=0`、`POST /api/admin/users`、`PATCH /api/admin/users/:id`。列表完整分页,仅返回账号身份、角色、状态和创建时间;不返回哈希、会话令牌或财务数据。角色为 `admin`、`user`、`readonly`。管理动作在事务内重查管理员权限;禁止修改自身角色或封禁自己,并保留可用管理员。
删除原管理员:先创建或设置另一位管理员,使用新管理员登录并完成首次改密,然后在后台原管理员所在行点击“删除管理员”。输入被删除账号名称和当前管理员密码后确认。`DELETE /api/admin/users/:id` 仅支持删除其他管理员;后端在事务中重查操作人权限、密码是否变化、被删除用户名和剩余管理员状态。至少保留另一位未封禁且完成改密的管理员,不允许删除自己。删除不可撤销,会级联清理该账号财务数据、会话和 MCP 授权,并删除私有图标;已发布共享图标保留。仍有其他管理员时,重启不会重新创建默认 admin。 删除原管理员:先创建或设置另一位管理员,使用新管理员登录并完成首次改密,然后在后台默认 admin 所在行点击“删除默认 admin”。输入被删除账号名称和当前管理员密码后确认。`DELETE /api/admin/users/:id` 仅允许删除名为 admin 且角色为管理员的默认账号,其他账号禁止删除;后端在事务中重查操作人权限、密码是否变化、被删除用户名和剩余管理员状态。至少保留另一位未封禁且完成改密的管理员,不允许删除自己。默认账号改名后也不显示删除入口。删除不可撤销,会级联清理该账号财务数据、会话和 MCP 授权,并删除私有图标;已发布共享图标保留。仍有其他管理员时,重启不会重新创建默认 admin。
角色/封禁状态变化会删除该用户全部会话、撤销 MCP/PAT/OAuth 授权、取消待确认草稿和已批准待兑换授权。解封不会恢复旧令牌。密码修改也会撤销既有 MCP 授权。HTTP 与 MCP 后端都会检查封禁、首次改密和只读权限;只读用户可管理自己的登录密码及只读连接,无法提交财务写入、草稿或确认业务操作。管理员仍遵循原有财务数据用户隔离。 角色/封禁状态变化会删除该用户全部会话、撤销 MCP/PAT/OAuth 授权、取消待确认草稿和已批准待兑换授权。解封不会恢复旧令牌。密码修改也会撤销既有 MCP 授权。HTTP 与 MCP 后端都会检查封禁、首次改密和只读权限;只读用户可管理自己的登录密码及只读连接,无法提交财务写入、草稿或确认业务操作。管理员仍遵循原有财务数据用户隔离。
@@ -24,3 +24,5 @@ ADMIN_PASSWORD=admin
运行隔离回归:在仓库根目录执行 `pnpm --filter @worthpath/api test:isolated`。测试库名使用随机 `wp_test_` 前缀,API 使用随机本机端口;完成或失败后清理自己创建的服务和数据库。管理员测试只在该隔离流程中运行,避免修改真实管理员。 运行隔离回归:在仓库根目录执行 `pnpm --filter @worthpath/api test:isolated`。测试库名使用随机 `wp_test_` 前缀,API 使用随机本机端口;完成或失败后清理自己创建的服务和数据库。管理员测试只在该隔离流程中运行,避免修改真实管理员。
历史 `005_account_icons`、`006_navigation_transfers`、`007_notes_display` 排在初始迁移之前的问题已由项目迁移入口修复,保留所有历史目录名称和 SQL 校验值。隔离测试通过正式迁移入口建立空库,再运行业务回归;不再使用 `db push` 或手动改列来代替迁移。空库失败恢复与部署命令见 [数据库迁移](database-migrations.md)。 历史 `005_account_icons`、`006_navigation_transfers`、`007_notes_display` 排在初始迁移之前的问题已由项目迁移入口修复,保留所有历史目录名称和 SQL 校验值。隔离测试通过正式迁移入口建立空库,再运行业务回归;不再使用 `db push` 或手动改列来代替迁移。空库失败恢复与部署命令见 [数据库迁移](database-migrations.md)。
2026-10-05 补充修复:删除按钮仅对默认 admin 显示,后端同时限制账号名称与管理员角色。普通用户、只读用户和非默认管理员均不能被删除,即使由管理员直接请求接口也拒绝;这些拒绝不会改变账号、会话、财务数据和授权。默认 admin 删除及保留可用管理员的保护继续通过隔离回归,详见 [更新验收](update-connections-repayment-users-2026-10-05.md)。
+11 -1
View File
@@ -24,7 +24,7 @@ OAuth 使用发现元数据、动态注册的公开客户端、授权码、S256
个人访问令牌仅作为支持自定义 Bearer 头客户端的补充。在网站验证当前密码,选择权限和 1、3、7、30 天、365 天或永久。完整值仅创建时展示,数据库只存 SHA-256 摘要。永久令牌数据库到期日为空,每个请求仍重新检查撤销和资源;为兼容 SDK 中间件,每次认证上下文有有限期验证断言,不改变令牌期限。永久令牌内部业务会话按天续期。OAuth 授权页可选择 1、3、7、30、365 天或永久,默认 30 天。访问令牌最长一小时且不超过有限授权期限;刷新时轮换摘要,不延长原授权期限。永久授权仍可撤销,内部业务会话有界并在刷新时重建。 个人访问令牌仅作为支持自定义 Bearer 头客户端的补充。在网站验证当前密码,选择权限和 1、3、7、30 天、365 天或永久。完整值仅创建时展示,数据库只存 SHA-256 摘要。永久令牌数据库到期日为空,每个请求仍重新检查撤销和资源;为兼容 SDK 中间件,每次认证上下文有有限期验证断言,不改变令牌期限。永久令牌内部业务会话按天续期。OAuth 授权页可选择 1、3、7、30、365 天或永久,默认 30 天。访问令牌最长一小时且不超过有限授权期限;刷新时轮换摘要,不延长原授权期限。永久授权仍可撤销,内部业务会话有界并在刷新时重建。
旧连接有 write 时,现在直接执行普通修改;旧全局策略不再阻止它。只想创建草稿时,撤销旧连接并重新授权 read draft。历史策略行保留但不参与任何权限判断;旧敏感工具草稿不可提交。 旧连接有 write 时,现在直接执行普通修改;旧全局策略不再阻止它。只想创建草稿时,在“我的连接”修改为 read draft,并验证当前密码。历史策略行保留但不参与任何权限判断;旧敏感工具草稿不可提交。
## 页面复制提示词配置 ## 页面复制提示词配置
@@ -143,3 +143,13 @@ NETWORK_ALLOW_HTTP=true 时支持配置的 HTTP MCP 服务和网页审核地址
### 统一网络配置 ### 统一网络配置
所有网络策略及中文说明见 apps/api/.env.example。API 与 Vite 网页开发服务共同读取 apps/api/.env,修改后重启。当前 .env 使用全网卡监听、Host/Origin 通配符、HTTP 和 HTTP 回调,Cookie 保持 HttpOnly 与 SameSite=strict。NETWORK_ALLOW_WILDCARD_ORIGINS 控制是否允许来源通配符;API_ALLOWED_HOSTS、MCP_ALLOWED_HOSTS 与 WEB_ALLOWED_HOSTS 分别控制各入口。限流开关、窗口和阈值也在环境文件中。详见 docs/network-settings.md。 所有网络策略及中文说明见 apps/api/.env.example。API 与 Vite 网页开发服务共同读取 apps/api/.env,修改后重启。当前 .env 使用全网卡监听、Host/Origin 通配符、HTTP 和 HTTP 回调,Cookie 保持 HttpOnly 与 SameSite=strict。NETWORK_ALLOW_WILDCARD_ORIGINS 控制是否允许来源通配符;API_ALLOWED_HOSTS、MCP_ALLOWED_HOSTS 与 WEB_ALLOWED_HOSTS 分别控制各入口。限流开关、窗口和阈值也在环境文件中。详见 docs/network-settings.md。
## 修改已有连接与负债账户还款
2026-10-05:在“我的连接”点击“修改权限”,OAuth 与个人令牌均可选择只读、草稿或直接写入,并独立设置隐藏账户读写。保存需当前密码,只能修改本人有效且未撤销的授权;OAuth 按授权到期日判断,访问令牌到期但授权仍有效时也可修改。权限立即用于后续认证,刷新使用最新授权范围且不延长授权期限;客户端指定已被移除的 scope 时会被拒绝,需要刷新请求使用允许范围或重新授权。实际范围发生变化会取消该连接待确认草稿,不会把旧草稿自动执行。
网站接口为 `PATCH /api/agent/connections/:id`,请求包含 `scopes` 和 `password`;不是 MCP 工具。只读系统账号只能授予 read/hidden_read。
`movement_create` 的 repay 从资产账户付款,目标支持借入债务和信用卡等负债账户。账户超额还款记为溢缴存款;独立债务仍拒绝超额还款。原币本金用 amount/received,fee 为负表示优惠、为正表示手续费;保留 repay 类型,双边余额及历史在同一事务提交,记录可编辑和撤销。
本次通过 57 项单元测试、9 项隔离 MySQL/REST/官方 SDK 集成测试、类型检查及生产构建;网页验证权限编辑表单、还款草稿预览、默认 admin 删除入口与余额颜色。详见 [更新验收](update-connections-repayment-users-2026-10-05.md)。
@@ -0,0 +1,40 @@
# 连接权限、账户还款与默认 admin 删除限制
完成时间:2026-10-05 20:23(UTC+8)。
## 已授权连接
在“设置与备份 → 连接 Agent → 我的连接”点击“修改权限”。OAuth 和个人令牌均可选择只读、草稿或直接写入,以及隐藏账户读写;需要验证当前密码。只能修改本人有效且未撤销的连接,授权期限保持不变。只读系统账号只能授予查询权限。
实际权限范围变化会取消本连接未确认草稿,助手需要重新生成,不会自动执行旧草稿。相同范围再次保存不取消草稿。每次认证读取最新权限,OAuth 刷新与权限编辑锁定同一授权记录,避免并发刷新覆盖新权限。OAuth 访问令牌过期、授权期限仍有效时也可以修改权限;客户端刷新时指定已移除的 scope 会被拒绝,需要使用允许范围或重新授权。
网站新增 `PATCH /api/agent/connections/:id`,参数为 `scopes` 和当前 `password`;OpenAPI 同步描述请求,不新增 MCP 管理工具。
## 负债账户还款
修复 MCP `movement_create(operation=repay)` 草稿确认拒绝信用卡等负债账户的问题。付款方必须是资产账户,收款方可以是借入债务或负债账户。保留 repay 记录类型,双边余额、历史和确认结果在同一事务提交;不把账户还款改成转账,也不创建债务关联。
amount/received 是各自原币本金,fee 为负表示优惠、为正表示手续费。同币种本金一致。负债账户超额还款保存为溢缴存款;独立债务仍禁止超额还款。配对还款记录可修改或删除,后续余额重放保持一致;隐藏权限、归属和账目状态检查仍生效。
## 仅默认 admin 可删除
按用户澄清后的规则,只有账号名为 admin 且角色为管理员的默认账号可删除,其他管理员、普通用户和只读用户都不能删除。前端仅为非当前登录的默认 admin 显示“删除默认 admin”;后端独立拒绝其他账号的删除请求。默认账号改名后不再符合删除条件。
删除默认 admin 需使用另一位已完成首次改密、未封禁的管理员登录,输入 admin 并验证当前管理员密码。不能删除当前登录账号,必须保留可用管理员。默认 admin 的私人数据和授权级联删除,共享图标保留。其他用户账号、会话、财务数据和授权不受被拒绝的删除请求影响。
## 余额颜色
账户卡片、详情、账户选择器、还款弹窗、历史余额和总览统一颜色:非负余额为绿色,欠款和透支为红色。负债账户存储的溢缴款转为正数显示绿色;显示符号继续使用十进制字符串,不改动数据库金额。
## 验证与清理
- 57 项单元测试通过;HTTP 测试显式设置并恢复网络开关,避免本地 .env 政策污染测试。
- 最终 8 项独立 MySQL/REST/官方 SDK 集成测试通过;另有正式数据库迁移回归通过。
- 新回归覆盖权限立即生效、范围不变保留草稿、变更取消草稿、OAuth 刷新与编辑并发、授权期限不延长、过期/撤销/跨用户/只读限制及 OpenAPI。
- 真正的账户还款草稿通过批量确认,优惠后的双方余额正确;修改、超额还款及撤销重算通过。
- 默认 admin 删除通过;普通用户、只读用户和非默认管理员删除均返回 403,保留账号与数据。
- 网页在独立临时库中验证权限表单、还款草稿预览、默认 admin 删除入口,以及余额绿色/欠款红色。未替用户确认真实 OAuth、草稿或删除账号。
- 前后端类型检查与生产构建通过。数据库表和字段缺失注释均为 0;本次无需新增迁移。
- 清理独立验收服务、测试数据库及一次性编辑/验收脚本;保留可重复运行的回归测试。截图仅保存在仓库外,不放入 docs。
未推送或部署。现有 compose.yaml 和 docker.md 改动保留。
+5
View File
@@ -7,6 +7,7 @@
> * 数据库的表和字段需要有注释 > * 数据库的表和字段需要有注释
> * 在docs目录不要保存任何图片 > * 在docs目录不要保存任何图片
> * 完成任务后完善本项目的README.md > * 完成任务后完善本项目的README.md
> * 完成任务后主动删除以后不需要的脚本和测试文件
更新要求: 更新要求:
@@ -98,3 +99,7 @@
- ~~优化审阅功能,快速阅读所有草稿并全选同意~~ — 已完成并验证:2026-10-05 12:27(UTC+8),见 docs/update-cash-drafts-login-2026-10-05.md - ~~优化审阅功能,快速阅读所有草稿并全选同意~~ — 已完成并验证:2026-10-05 12:27(UTC+8),见 docs/update-cash-drafts-login-2026-10-05.md
- ~~添加记住账号密码的功能~~ — 已完成并验证:2026-10-05 12:27(UTC+8),见 docs/update-cash-drafts-login-2026-10-05.md - ~~添加记住账号密码的功能~~ — 已完成并验证:2026-10-05 12:27(UTC+8),见 docs/update-cash-drafts-login-2026-10-05.md
- ~~修复 MCP 同批新增多个账号时后续草稿提示账目已变化~~ — 已完成并验证:2026-10-04 22:53(UTC+8),见 docs/fix-mcp-create-drafts-2026-10-04.md - ~~修复 MCP 同批新增多个账号时后续草稿提示账目已变化~~ — 已完成并验证:2026-10-04 22:53(UTC+8),见 docs/fix-mcp-create-drafts-2026-10-04.md
- ~~在已经添加的连接中可以修改权限,并且使用OAuth的可以设置直接写入的权限~~ — 已完成并验证:2026-10-05 20:23(UTC+8),见 docs/update-connections-repayment-users-2026-10-05.md
- ~~通过MCP 对账号还款时 确定时出现请选择有效的资产账户和对应借入或借出债务。本批修改未执行,请刷新后重新审阅。~~ — 已完成并验证:2026-10-05 20:23(UTC+8),见 docs/update-connections-repayment-users-2026-10-05.md
- ~~删除账号功能,只能删除admin默认的这个账号,对其他账号无法被删除~~ — 已完成并验证:2026-10-05 20:23(UTC+8);按用户澄清:仅默认 admin 可删除,其他账号禁止删除,见 docs/update-connections-repayment-users-2026-10-05.md
- ~~余额显示绿色,欠款为红色~~ — 已完成并验证:2026-10-05 20:23(UTC+8),见 docs/update-connections-repayment-users-2026-10-05.md