refactor: remove deprecated apiKey from site management and update related functionality
- Removed apiKey from SiteRow type and related forms in Sites component. - Updated site saving logic to handle site creation without apiKey. - Adjusted tests to ensure apiKey is not included in site editor state. - Refactored Tokens component to support embedded mode and redirect logic. - Added migration tests for site apiKey to account migration service. - Implemented siteApiKeyMigrationService to handle migration of apiKey to account tokens. - Enhanced tests for segmented connections in Accounts and redirect logic for Tokens.
This commit is contained in:
+767
-502
File diff suppressed because it is too large
Load Diff
+5
-18
@@ -23,7 +23,6 @@ type SiteRow = {
|
||||
externalCheckinUrl?: string | null;
|
||||
platform?: string;
|
||||
status?: string;
|
||||
apiKey?: string;
|
||||
proxyUrl?: string | null;
|
||||
globalWeight?: number;
|
||||
isPinned?: boolean;
|
||||
@@ -188,7 +187,6 @@ export default function Sites() {
|
||||
url: form.url.trim(),
|
||||
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
||||
platform: form.platform.trim(),
|
||||
apiKey: form.apiKey.trim(),
|
||||
proxyUrl: form.proxyUrl.trim(),
|
||||
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
|
||||
};
|
||||
@@ -201,8 +199,12 @@ export default function Sites() {
|
||||
try {
|
||||
const action = buildSiteSaveAction(editor, payload);
|
||||
if (action.kind === 'add') {
|
||||
await api.addSite(action.payload);
|
||||
const created = await api.addSite(action.payload);
|
||||
toast.success(`站点 "${payload.name}" 已添加`);
|
||||
const createdSiteId = Number(created?.id) || 0;
|
||||
if (createdSiteId > 0) {
|
||||
navigate(`/accounts?segment=apikey&create=1&siteId=${createdSiteId}`);
|
||||
}
|
||||
} else {
|
||||
await api.updateSite(action.id, action.payload);
|
||||
toast.success(`站点 "${payload.name}" 已更新`);
|
||||
@@ -407,21 +409,6 @@ export default function Sites() {
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="API Key(可选)"
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="出站代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
||||
value={form.proxyUrl}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
@@ -9,6 +9,9 @@ import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
type SyncStatus = 'success' | 'skipped' | 'failed';
|
||||
type TokensPanelProps = {
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
type AccountTokenSyncResult = {
|
||||
status?: string;
|
||||
@@ -25,8 +28,22 @@ type AccountTokenSyncResult = {
|
||||
};
|
||||
};
|
||||
|
||||
const resolveAccountCredentialMode = (account: any): 'session' | 'apikey' => {
|
||||
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
|
||||
if (rawMode === 'apikey') return 'apikey';
|
||||
if (rawMode === 'session') return 'session';
|
||||
if (typeof account?.capabilities?.proxyOnly === 'boolean') {
|
||||
return account.capabilities.proxyOnly ? 'apikey' : 'session';
|
||||
}
|
||||
return typeof account?.accessToken === 'string' && account.accessToken.trim()
|
||||
? 'session'
|
||||
: 'apikey';
|
||||
};
|
||||
|
||||
const isAccountSyncable = (account: any) =>
|
||||
account?.status === 'active' && account?.site?.status !== 'disabled';
|
||||
resolveAccountCredentialMode(account) === 'session'
|
||||
&& account?.status === 'active'
|
||||
&& account?.site?.status !== 'disabled';
|
||||
|
||||
const resolveSyncStatus = (result: AccountTokenSyncResult | null | undefined): SyncStatus => {
|
||||
const raw = String(result?.status || '').toLowerCase();
|
||||
@@ -83,7 +100,7 @@ function isTruthyFlag(input: string | null): boolean {
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||
}
|
||||
|
||||
export default function Tokens() {
|
||||
export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const initialCreateForm = {
|
||||
@@ -362,9 +379,9 @@ export default function Tokens() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className={embedded ? '' : 'animate-fade-in'}>
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{tr('令牌管理')}</h2>
|
||||
{!embedded ? <h2 className="page-title">{tr('账号令牌')}</h2> : <div />}
|
||||
<div className="page-actions">
|
||||
<div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
@@ -646,3 +663,11 @@ export default function Tokens() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Tokens() {
|
||||
const location = useLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set('segment', 'tokens');
|
||||
const nextSearch = params.toString();
|
||||
return <Navigate to={`/accounts${nextSearch ? `?${nextSearch}` : ''}`} replace />;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('Accounts proxy-only expired state', () => {
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<MemoryRouter initialEntries={['/accounts?segment=apikey']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
getAccountTokens: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Accounts segmented connections view', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows only apikey connections in the apikey segment and labels unnamed ones as API Key 连接', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
username: 'session-user',
|
||||
accessToken: 'session-token',
|
||||
apiToken: 'sk-session',
|
||||
status: 'active',
|
||||
credentialMode: 'session',
|
||||
capabilities: { canCheckin: true, canRefreshBalance: true, proxyOnly: false },
|
||||
site: { id: 10, name: 'Session Site', platform: 'new-api', status: 'active', url: 'https://session.example.com' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: '',
|
||||
accessToken: '',
|
||||
apiToken: 'sk-apikey',
|
||||
status: 'active',
|
||||
credentialMode: 'apikey',
|
||||
capabilities: { canCheckin: false, canRefreshBalance: false, proxyOnly: true },
|
||||
site: { id: 11, name: 'Key Site', platform: 'new-api', status: 'active', url: 'https://key.example.com' },
|
||||
},
|
||||
]);
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
|
||||
{ id: 11, name: 'Key Site', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccountTokens.mockResolvedValue([]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts?segment=apikey']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('连接管理');
|
||||
expect(rendered).toContain('API Key 连接');
|
||||
expect(rendered).toContain('Key Site');
|
||||
expect(rendered).not.toContain('session-user');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildSiteSaveAction } from './sitesEditor.js';
|
||||
import { buildSiteSaveAction, emptySiteForm, siteFormFromSite } from './sitesEditor.js';
|
||||
|
||||
describe('buildSiteSaveAction', () => {
|
||||
it('returns add action in add mode', () => {
|
||||
@@ -10,7 +10,6 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://a.example.com/',
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-1',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
globalWeight: '1.2',
|
||||
},
|
||||
@@ -23,7 +22,6 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://a.example.com/',
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-1',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
globalWeight: '1.2',
|
||||
},
|
||||
@@ -38,7 +36,6 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://b.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: 'one-api',
|
||||
apiKey: '',
|
||||
proxyUrl: '',
|
||||
globalWeight: '0.8',
|
||||
},
|
||||
@@ -52,7 +49,6 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://b.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: 'one-api',
|
||||
apiKey: '',
|
||||
proxyUrl: '',
|
||||
globalWeight: '0.8',
|
||||
},
|
||||
@@ -68,11 +64,23 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://c.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: '',
|
||||
apiKey: '',
|
||||
proxyUrl: '',
|
||||
globalWeight: '1',
|
||||
},
|
||||
),
|
||||
).toThrow('editingSiteId is required in edit mode');
|
||||
});
|
||||
|
||||
it('does not expose deprecated apiKey in site editor state', () => {
|
||||
expect(emptySiteForm()).not.toHaveProperty('apiKey');
|
||||
expect(siteFormFromSite({
|
||||
name: 'site-d',
|
||||
url: 'https://d.example.com',
|
||||
externalCheckinUrl: null,
|
||||
platform: 'new-api',
|
||||
proxyUrl: null,
|
||||
globalWeight: 1,
|
||||
apiKey: 'sk-legacy-site-key',
|
||||
})).not.toHaveProperty('apiKey');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ export type SiteForm = {
|
||||
url: string;
|
||||
externalCheckinUrl: string;
|
||||
platform: string;
|
||||
apiKey: string;
|
||||
proxyUrl: string;
|
||||
globalWeight: string;
|
||||
};
|
||||
@@ -17,7 +16,7 @@ type SiteSaveAction =
|
||||
| { kind: 'update'; id: number; payload: SiteForm };
|
||||
|
||||
export function emptySiteForm(): SiteForm {
|
||||
return { name: '', url: '', externalCheckinUrl: '', platform: '', apiKey: '', proxyUrl: '', globalWeight: '1' };
|
||||
return { name: '', url: '', externalCheckinUrl: '', platform: '', proxyUrl: '', globalWeight: '1' };
|
||||
}
|
||||
|
||||
export function siteFormFromSite(site: Partial<SiteForm> & {
|
||||
@@ -32,7 +31,6 @@ export function siteFormFromSite(site: Partial<SiteForm> & {
|
||||
url: site.url ?? '',
|
||||
externalCheckinUrl: site.externalCheckinUrl ?? '',
|
||||
platform: site.platform ?? '',
|
||||
apiKey: site.apiKey ?? '',
|
||||
proxyUrl: site.proxyUrl ?? '',
|
||||
globalWeight,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Tokens from './Tokens.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccountTokens: vi.fn().mockResolvedValue([]),
|
||||
getAccounts: vi.fn().mockResolvedValue([]),
|
||||
getAccountTokenGroups: vi.fn().mockResolvedValue({ groups: ['default'] }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <div>{`${location.pathname}${location.search}`}</div>;
|
||||
}
|
||||
|
||||
describe('Tokens legacy route redirect', () => {
|
||||
it('redirects /tokens to the merged accounts tokens segment', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={['/tokens?create=1&accountId=23&model=gpt-4.1']}>
|
||||
<Routes>
|
||||
<Route path="/tokens" element={<Tokens />} />
|
||||
<Route path="/accounts" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const rendered = JSON.stringify(root?.toJSON());
|
||||
expect(rendered).toContain('/accounts?');
|
||||
expect(rendered).toContain('segment=tokens');
|
||||
expect(rendered).toContain('create=1');
|
||||
expect(rendered).toContain('accountId=23');
|
||||
expect(rendered).toContain('model=gpt-4.1');
|
||||
root?.unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user