Fix MCP permissions and repayments; restrict deletion to default admin
This commit is contained in:
1 parent
35bd2e1828
commit
794274d31b
21 files changed
+702
-52
No files matched your search
@@ -83,6 +83,7 @@ async function main() {
|
||||
'test/mcp.test.ts',
|
||||
'test/oauth-duration.test.ts',
|
||||
'test/database-migrations.test.ts',
|
||||
'test/connection-repayment-integration.test.ts',
|
||||
];
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
|
||||
@@ -135,7 +135,8 @@ export class AdminService {
|
||||
throw new ForbiddenException('管理员权限已变更');
|
||||
const target = await tx.user.findUnique({ where: { id } });
|
||||
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)
|
||||
throw new BadRequestException('确认账号名称不一致,请重新核对');
|
||||
if (
|
||||
|
||||
@@ -193,7 +193,7 @@ export class AgentCatalogue {
|
||||
),
|
||||
write(
|
||||
'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() }),
|
||||
(r, p) => transfers.create(r, p),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
NotFoundException,
|
||||
Delete,
|
||||
Req,
|
||||
Param,
|
||||
@@ -11,9 +13,11 @@ import {
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { compare } from 'bcryptjs';
|
||||
import { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { loginInput } from '../user-access';
|
||||
import { Database } from '../database';
|
||||
import { AuthService, UserRequest } from '../auth';
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@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) {
|
||||
z.string().uuid().parse(id);
|
||||
await this.db.agentGrant.updateMany({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { networkConfig } from '../network';
|
||||
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { Database } from '../database';
|
||||
@@ -324,6 +325,9 @@ export class AgentOAuth implements OAuthServerProvider {
|
||||
) {
|
||||
this.resource(resource);
|
||||
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) } });
|
||||
if (
|
||||
!row ||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { scopeInput } from './mcp/oauth';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { metalConfig, metalHoldingInput } from './metals';
|
||||
@@ -38,6 +39,9 @@ export function setupOpenApi(app: INestApplication) {
|
||||
'PATCH /api/admin/users/{id}': adminUpdateInput,
|
||||
'DELETE /api/admin/users/{id}': adminDeleteInput,
|
||||
'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/positions': positionInput,
|
||||
'PATCH /api/positions/{id}': positionMeta,
|
||||
@@ -101,6 +105,12 @@ export function setupOpenApi(app: INestApplication) {
|
||||
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') {
|
||||
operation.parameters = [
|
||||
...['from', 'to'].map((name) => ({
|
||||
|
||||
@@ -183,10 +183,12 @@ export async function executeMovement(
|
||||
(v.operation !== 'transfer' && source.side !== 'asset') ||
|
||||
(v.operation === 'transfer'
|
||||
? 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))
|
||||
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
|
||||
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
|
||||
@@ -235,7 +237,7 @@ export async function executeMovement(
|
||||
notes: v.notes,
|
||||
},
|
||||
});
|
||||
if (v.operation !== 'transfer')
|
||||
if (v.operation !== 'transfer' && target.kind === 'debt')
|
||||
await tx.positionLink.upsert({
|
||||
where: { sourceId_targetId: { 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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -6,6 +6,8 @@ test('HTTP resources, web links and client redirects work beyond loopback in dev
|
||||
const before = { ...process.env };
|
||||
try {
|
||||
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_WEB_URL = 'http://192.0.2.10:5173';
|
||||
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 {
|
||||
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];
|
||||
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 };
|
||||
try {
|
||||
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_WEB_URL = 'https://worthpath.example';
|
||||
assert.throws(() => urls(), /HTTPS/);
|
||||
@@ -61,7 +71,13 @@ test('production requires HTTPS for configured endpoints and rejects non-loopbac
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
} 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];
|
||||
else process.env[k] = before[k];
|
||||
}
|
||||
|
||||
@@ -85,7 +85,11 @@ export function AccountPicker({
|
||||
{p.iconId && <img src={iconUrl(p.iconId)} alt="" />}
|
||||
<span>
|
||||
{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>
|
||||
{p.id === value && <span>✓</span>}
|
||||
</button>
|
||||
|
||||
+19
-15
@@ -183,18 +183,20 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
|
||||
>
|
||||
{account.banned ? tr('解除封禁') : tr('封禁账号')}
|
||||
</button>
|
||||
{account.role === 'admin' && account.id !== user.id && (
|
||||
<button
|
||||
className="danger"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setDeleting(account);
|
||||
setConfirmation('');
|
||||
}}
|
||||
>
|
||||
{tr('删除管理员')}
|
||||
</button>
|
||||
)}
|
||||
{account.username === 'admin' &&
|
||||
account.role === 'admin' &&
|
||||
account.id !== user.id && (
|
||||
<button
|
||||
className="danger"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setDeleting(account);
|
||||
setConfirmation('');
|
||||
}}
|
||||
>
|
||||
{tr('删除默认 admin')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -220,9 +222,9 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
|
||||
</section>
|
||||
{deleting && (
|
||||
<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>
|
||||
{tr('删除管理员')} · {deleting.username}
|
||||
{tr('删除账号')} · {deleting.username}
|
||||
</h2>
|
||||
<p className="danger-text">
|
||||
{tr(
|
||||
@@ -230,7 +232,9 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
|
||||
)}
|
||||
</p>
|
||||
<p className="muted">
|
||||
{tr('必须保留另一位已完成改密且未封禁的管理员。不能删除当前登录账号。')}
|
||||
{tr(
|
||||
'仅允许删除默认 admin 账号;其他账号禁止删除。必须保留另一位已完成改密且未封禁的管理员,不能删除当前登录账号。',
|
||||
)}
|
||||
</p>
|
||||
<form onSubmit={remove}>
|
||||
<label className="field">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import { AgentDrafts } from './AgentDrafts';
|
||||
import { AgentDrafts, ReviewDialog } from './AgentDrafts';
|
||||
import { toolLabel, statusLabel, permissionLabel } from './agent-display';
|
||||
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);
|
||||
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 [data, setData] = useState<Management | null>(null),
|
||||
[view, setView] = useState('connect'),
|
||||
@@ -322,7 +326,9 @@ export function AgentConnections({ changed }: { changed?: () => void }) {
|
||||
<>
|
||||
<section className="agent-card">
|
||||
<h3>已授权连接</h3>
|
||||
<p className="muted">最近 100 条 · 撤销后助手将无法继续访问。</p>
|
||||
<p className="muted">
|
||||
最近 100 条 · 可修改权限,OAuth 也支持直接写入;撤销后助手无法继续访问。
|
||||
</p>
|
||||
{!data.grants.length && (
|
||||
<div className="agent-empty">
|
||||
<h4>还没有连接</h4>
|
||||
@@ -357,18 +363,40 @@ export function AgentConnections({ changed }: { changed?: () => void }) {
|
||||
</small>
|
||||
</div>
|
||||
{!g.revokedAt && (
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(async () => {
|
||||
await api('/agent/connections/' + g.id, 'DELETE');
|
||||
showToast('连接已撤销', 'success');
|
||||
})
|
||||
}
|
||||
>
|
||||
撤销连接
|
||||
</button>
|
||||
<div className="actions">
|
||||
{active.some((a) => a.id === g.id) && (
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(g);
|
||||
setEditPermission(
|
||||
g.scopes.includes('write')
|
||||
? 'write'
|
||||
: g.scopes.includes('draft')
|
||||
? '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>
|
||||
))}
|
||||
@@ -591,6 +619,104 @@ export function AgentConnections({ changed }: { changed?: () => void }) {
|
||||
</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 && (
|
||||
<AgentDrafts
|
||||
initialId={review === 'all' ? undefined : review}
|
||||
|
||||
@@ -9,10 +9,12 @@ export function ReviewDialog({
|
||||
children,
|
||||
close,
|
||||
busy = false,
|
||||
title = '审阅草稿',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
close: () => void;
|
||||
busy?: boolean;
|
||||
title?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -38,7 +40,7 @@ export function ReviewDialog({
|
||||
className="modal draft-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="审阅草稿"
|
||||
aria-label={title}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && !busy) {
|
||||
e.preventDefault();
|
||||
@@ -73,7 +75,7 @@ export function ReviewDialog({
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<h2>审阅草稿</h2>
|
||||
<h2>{title}</h2>
|
||||
<button className="icon" aria-label="关闭" disabled={busy} onClick={close}>
|
||||
×
|
||||
</button>
|
||||
|
||||
+14
-6
@@ -1260,7 +1260,7 @@ export default function App() {
|
||||
</div>
|
||||
<strong
|
||||
className={
|
||||
cls === 'debt' || value.startsWith('-') ? 'danger-text' : undefined
|
||||
cls === 'debt' || value.startsWith('-') ? 'danger-text' : 'income-text'
|
||||
}
|
||||
>
|
||||
{money(value, user.baseCurrency)}
|
||||
@@ -1468,7 +1468,9 @@ export default function App() {
|
||||
)}
|
||||
{p.archived ? tr(' · 已归档') : ''}
|
||||
</span>
|
||||
<h2 className={positionAmount(p).startsWith('-') ? 'danger-text' : undefined}>
|
||||
<h2
|
||||
className={positionAmount(p).startsWith('-') ? 'danger-text' : 'income-text'}
|
||||
>
|
||||
{p.valuationAvailable === false
|
||||
? tr('待估值')
|
||||
: money(positionAmount(p), p.currency)}
|
||||
@@ -1922,7 +1924,7 @@ export default function App() {
|
||||
</p>
|
||||
<strong
|
||||
className={
|
||||
positionAmount(p).startsWith('-') ? 'danger-text' : undefined
|
||||
positionAmount(p).startsWith('-') ? 'danger-text' : 'income-text'
|
||||
}
|
||||
>
|
||||
{p.valuationAvailable === false
|
||||
@@ -3474,17 +3476,23 @@ function HistoryTable({
|
||||
</small>
|
||||
</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)}
|
||||
</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)}
|
||||
</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)}
|
||||
</td>
|
||||
|
||||
@@ -137,7 +137,7 @@ export function QuickRepayment({
|
||||
const source = choices.find((p) => p.id === sourceId);
|
||||
return (
|
||||
<section>
|
||||
<p>
|
||||
<p className={positionAmount(target).startsWith('-') ? 'danger-text' : 'income-text'}>
|
||||
{tr('还款账户')}:{target.name} · {money(positionAmount(target), target.currency)}
|
||||
</p>
|
||||
<p className="muted">
|
||||
|
||||
@@ -1276,7 +1276,7 @@ footer {
|
||||
color: #c73542 !important;
|
||||
}
|
||||
.income-text {
|
||||
color: #1b7855;
|
||||
color: #1b7855 !important;
|
||||
}
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
|
||||
Reference in new issue
Block a user