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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,6 +46,12 @@ function normalizeTokenValue(token: string | null | undefined): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function isMaskedTokenValue(token: string | null | undefined): boolean {
|
||||
const value = (token || '').trim();
|
||||
if (!value) return false;
|
||||
return value.includes('*') || value.includes('•');
|
||||
}
|
||||
|
||||
function normalizeTokenGroup(value: string | null | undefined, tokenName?: string | null): string | null {
|
||||
const explicit = (value || '').trim();
|
||||
if (explicit.length > 0) return explicit;
|
||||
@@ -200,11 +206,16 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let maskedSkipped = 0;
|
||||
let index = existing.length + 1;
|
||||
|
||||
for (const upstream of upstreamTokens) {
|
||||
const tokenValue = normalizeTokenValue(upstream.key);
|
||||
if (!tokenValue) continue;
|
||||
if (isMaskedTokenValue(tokenValue)) {
|
||||
maskedSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokenName = normalizeTokenName(upstream.name, index);
|
||||
const enabled = upstream.enabled ?? true;
|
||||
@@ -259,6 +270,7 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
|
||||
return {
|
||||
created,
|
||||
updated,
|
||||
maskedSkipped,
|
||||
total: existing.length,
|
||||
defaultTokenId: repaired?.id || null,
|
||||
};
|
||||
@@ -295,4 +307,4 @@ export async function listTokensWithRelations(accountId?: number) {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const CHECKIN_ALREADY_TOKEN = 'checkin-already-token';
|
||||
const CHECKIN_INVALID_URL_TOKEN = 'checkin-invalid-url-token';
|
||||
const CHECKIN_CLOUDFLARE_530_TOKEN = 'checkin-cloudflare-530-token';
|
||||
const BALANCE_FAIL_TOKEN = 'balance-fail-token';
|
||||
const GROUP_EXPIRED_TOKEN = 'group-expired-token';
|
||||
const SHIELD_LOGIN_USERNAME = 'shield-user';
|
||||
const SHIELD_LOGIN_PASSWORD = 'shield-pass';
|
||||
const SHIELD_LOGIN_TOKEN = 'login-session-token';
|
||||
@@ -366,6 +367,19 @@ describe('NewApiAdapter', () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === '/api/user/self/groups') {
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${GROUP_EXPIRED_TOKEN}`) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'access token expired' }));
|
||||
return;
|
||||
}
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === 'Bearer session-token') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, data: { default: true, gemini: true } }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (req.url === '/api/user/sign_in') {
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${CHECKIN_ALREADY_TOKEN}`)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -556,6 +570,20 @@ describe('NewApiAdapter', () => {
|
||||
expect(result.message).toBe('今天已经签到过啦');
|
||||
});
|
||||
|
||||
it('returns clean groups from data object without envelope keys', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const groups = await adapter.getUserGroups(baseUrl, 'session-token', 11494);
|
||||
|
||||
expect(groups).toEqual(['default', 'gemini']);
|
||||
expect(groups).not.toContain('success');
|
||||
expect(groups).not.toContain('message');
|
||||
});
|
||||
|
||||
it('throws expired-session error when group endpoint reports invalid access token', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
await expect(adapter.getUserGroups(baseUrl, GROUP_EXPIRED_TOKEN, 11494)).rejects.toThrow('账号会话可能已过期');
|
||||
});
|
||||
|
||||
it('sends all compatibility user-id headers when userId is known', async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err?: Error) => (err ? reject(err) : resolve()));
|
||||
|
||||
@@ -248,6 +248,10 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
}
|
||||
|
||||
private parseGroupKeys(payload: any): string[] {
|
||||
if (payload && typeof payload === 'object' && payload?.success === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const source = payload?.data ?? payload;
|
||||
if (Array.isArray(source)) {
|
||||
return source
|
||||
@@ -258,12 +262,28 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
if (source && typeof source === 'object') {
|
||||
return Object.keys(source)
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => !['success', 'message', 'code', 'data', 'error'].includes(key.toLowerCase()))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private resolveGroupFetchErrorMessage(payload: any): string {
|
||||
const message = typeof payload?.message === 'string' ? payload.message.trim() : '';
|
||||
const normalized = message.toLowerCase();
|
||||
const indicatesExpired = normalized.includes('expired')
|
||||
|| normalized.includes('invalid token')
|
||||
|| normalized.includes('access token')
|
||||
|| normalized.includes('unauthorized')
|
||||
|| normalized.includes('forbidden')
|
||||
|| normalized.includes('未登录')
|
||||
|| normalized.includes('登录')
|
||||
|| normalized.includes('过期');
|
||||
if (indicatesExpired) return '账号会话可能已过期,请重新登录后再拉取分组';
|
||||
return message || '拉取分组失败';
|
||||
}
|
||||
|
||||
private normalizeTokenItems(items: any[]): ApiTokenInfo[] {
|
||||
const normalized: ApiTokenInfo[] = [];
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
@@ -1141,11 +1161,15 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
async getUserGroups(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string[]> {
|
||||
const resolvedUserId = platformUserId || await this.discoverUserId(baseUrl, accessToken);
|
||||
const dedupe = (groups: string[]) => Array.from(new Set(groups.map((item) => item.trim()).filter(Boolean)));
|
||||
let terminalError: string | null = null;
|
||||
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/user/self/groups`, {
|
||||
headers: this.authHeaders(accessToken, resolvedUserId || undefined),
|
||||
});
|
||||
if (res?.success === false) {
|
||||
terminalError = this.resolveGroupFetchErrorMessage(res);
|
||||
}
|
||||
const parsed = dedupe(this.parseGroupKeys(res));
|
||||
if (parsed.length > 0) return parsed;
|
||||
} catch {}
|
||||
@@ -1154,6 +1178,9 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}/api/user_group_map`, {
|
||||
headers: this.authHeaders(accessToken, resolvedUserId || undefined),
|
||||
});
|
||||
if (res?.success === false) {
|
||||
terminalError = this.resolveGroupFetchErrorMessage(res);
|
||||
}
|
||||
const parsed = dedupe(this.parseGroupKeys(res));
|
||||
if (parsed.length > 0) return parsed;
|
||||
} catch {}
|
||||
@@ -1165,17 +1192,27 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
|
||||
try {
|
||||
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/self/groups`, { headers });
|
||||
if (res?.success === false) {
|
||||
terminalError = this.resolveGroupFetchErrorMessage(res);
|
||||
}
|
||||
const parsed = dedupe(this.parseGroupKeys(res));
|
||||
if (parsed.length > 0) return parsed;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user_group_map`, { headers });
|
||||
if (res?.success === false) {
|
||||
terminalError = this.resolveGroupFetchErrorMessage(res);
|
||||
}
|
||||
const parsed = dedupe(this.parseGroupKeys(res));
|
||||
if (parsed.length > 0) return parsed;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (terminalError) {
|
||||
throw new Error(terminalError);
|
||||
}
|
||||
|
||||
return ['default'];
|
||||
}
|
||||
|
||||
|
||||
@@ -129,24 +129,49 @@ export class OneApiAdapter extends BasePlatformAdapter {
|
||||
|
||||
async getUserGroups(baseUrl: string, accessToken: string): Promise<string[]> {
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
const resolveGroupFetchErrorMessage = (payload: any): string => {
|
||||
const message = typeof payload?.message === 'string' ? payload.message.trim() : '';
|
||||
const normalized = message.toLowerCase();
|
||||
const indicatesExpired = normalized.includes('expired')
|
||||
|| normalized.includes('invalid token')
|
||||
|| normalized.includes('access token')
|
||||
|| normalized.includes('unauthorized')
|
||||
|| normalized.includes('forbidden')
|
||||
|| normalized.includes('未登录')
|
||||
|| normalized.includes('登录')
|
||||
|| normalized.includes('过期');
|
||||
if (indicatesExpired) return '账号会话可能已过期,请重新登录后再拉取分组';
|
||||
return message || '拉取分组失败';
|
||||
};
|
||||
const extractGroupKeys = (payload: any): string[] => {
|
||||
if (payload && typeof payload === 'object' && payload?.success === false) return [];
|
||||
const source = payload?.data || payload;
|
||||
if (!source || typeof source !== 'object') return [];
|
||||
return Object.keys(source).map((key) => key.trim()).filter(Boolean);
|
||||
return Object.keys(source)
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => !['success', 'message', 'code', 'data', 'error'].includes(key.toLowerCase()))
|
||||
.filter(Boolean);
|
||||
};
|
||||
let terminalError: string | null = null;
|
||||
|
||||
try {
|
||||
const groupMap = await this.fetchJson<any>(`${baseUrl}/api/user_group_map`, { headers });
|
||||
if (groupMap?.success === false) terminalError = resolveGroupFetchErrorMessage(groupMap);
|
||||
const keys = extractGroupKeys(groupMap);
|
||||
if (keys.length > 0) return Array.from(new Set(keys));
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const groups = await this.fetchJson<any>(`${baseUrl}/api/user/self/groups`, { headers });
|
||||
if (groups?.success === false) terminalError = resolveGroupFetchErrorMessage(groups);
|
||||
const keys = extractGroupKeys(groups);
|
||||
if (keys.length > 0) return Array.from(new Set(keys));
|
||||
} catch {}
|
||||
|
||||
if (terminalError) {
|
||||
throw new Error(terminalError);
|
||||
}
|
||||
|
||||
return ['default'];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user