feat: add delete confirmation modal and enhance token handling

- Implemented a DeleteConfirmModal component for confirming deletions across various pages (Accounts, Sites, Tokens).
- Updated Accounts and Sites pages to utilize the new modal for single and batch deletions.
- Enhanced TokensPanel to support group selection and improved token deletion handling with confirmation.
- Added functionality to skip confirmation for batch actions and handle token group fetching with error management.
- Improved error handling for expired sessions in NewApiAdapter and OneApiAdapter.
- Added tests for new delete confirmation behavior and token editing interactions.
This commit is contained in:
ciat-777
2026-03-14 13:31:15 +08:00
parent ad791d87bb
commit 1a1f0452b3
18 changed files with 595 additions and 58 deletions
@@ -138,6 +138,37 @@ describe('account tokens sync routes with site status', () => {
expect(tokenRows.length).toBe(0);
});
it('skips masked upstream token values and does not store them', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: 'sk-abc***xyz', enabled: true },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: false,
status: 'skipped',
reason: 'upstream_masked_tokens',
maskedSkipped: 1,
total: 0,
created: 0,
updated: 0,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows.length).toBe(0);
});
it('rejects sync and token management for apikey connections', async () => {
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
await db.update(schema.accounts)
@@ -450,4 +481,26 @@ describe('account tokens sync routes with site status', () => {
const existing = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
expect(existing).toBeDefined();
});
it('rejects retrieving token value when stored token is masked', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: true,
isDefault: false,
}).returning().get();
const response = await app.inject({
method: 'GET',
url: `/api/account-tokens/${token.id}/value`,
});
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({
success: false,
});
});
});
+24 -1
View File
@@ -3,6 +3,7 @@ import { and, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import {
ensureDefaultTokenForAccount,
isMaskedTokenValue,
listTokensWithRelations,
normalizeTokenForDisplay,
maskToken,
@@ -32,6 +33,7 @@ type SyncExecutionResult = {
synced: boolean;
created: number;
updated: number;
maskedSkipped?: number;
total: number;
defaultTokenId?: number | null;
};
@@ -261,6 +263,16 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
}
const synced = await syncTokensFromUpstream(accountId, tokens);
if ((synced.created + synced.updated) === 0 && synced.maskedSkipped > 0) {
return {
...base,
status: 'skipped',
reason: 'upstream_masked_tokens',
message: `上游仅返回脱敏令牌(含*),跳过 ${synced.maskedSkipped} 条,无法同步明文令牌`,
synced: false,
...synced,
};
}
return {
...base,
status: 'synced',
@@ -606,7 +618,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
};
});
app.put<{ Params: { id: string }; Body: { name?: string; token?: string; enabled?: boolean; isDefault?: boolean; source?: string } }>('/api/account-tokens/:id', async (request, reply) => {
app.put<{ Params: { id: string }; Body: { name?: string; token?: string; group?: string; enabled?: boolean; isDefault?: boolean; source?: string } }>('/api/account-tokens/:id', async (request, reply) => {
const tokenId = Number.parseInt(request.params.id, 10);
if (Number.isNaN(tokenId)) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
@@ -640,6 +652,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
updates.token = tokenValue;
}
if (body.group !== undefined) {
updates.tokenGroup = (body.group || '').trim() || null;
}
if (body.enabled !== undefined) updates.enabled = body.enabled;
if (body.source !== undefined) updates.source = body.source;
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
@@ -707,6 +723,13 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
if (isMaskedTokenValue(row.account_tokens.token)) {
return reply.code(409).send({
success: false,
message: '当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。',
});
}
const tokenValue = normalizeTokenForDisplay(row.account_tokens.token, row.sites.platform);
return {
success: true,