forked from boos/BoosAPI
feat: surface oauth quota details
This commit is contained in:
@@ -812,6 +812,31 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
accountId: 'chatgpt-account-123',
|
accountId: 'chatgpt-account-123',
|
||||||
email: 'codex-user@example.com',
|
email: 'codex-user@example.com',
|
||||||
planType: 'team',
|
planType: 'team',
|
||||||
|
idToken: buildJwt({
|
||||||
|
email: 'codex-user@example.com',
|
||||||
|
'https://api.openai.com/auth': {
|
||||||
|
chatgpt_account_id: 'chatgpt-account-123',
|
||||||
|
chatgpt_plan_type: 'team',
|
||||||
|
chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z',
|
||||||
|
chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
quota: {
|
||||||
|
status: 'supported',
|
||||||
|
source: 'reverse_engineered',
|
||||||
|
lastSyncAt: '2026-03-17T08:00:00.000Z',
|
||||||
|
lastLimitResetAt: '2026-03-17T13:00:00.000Z',
|
||||||
|
windows: {
|
||||||
|
fiveHour: {
|
||||||
|
supported: false,
|
||||||
|
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
|
||||||
|
},
|
||||||
|
sevenDay: {
|
||||||
|
supported: false,
|
||||||
|
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
modelDiscoveryStatus: 'abnormal',
|
modelDiscoveryStatus: 'abnormal',
|
||||||
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
||||||
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
||||||
@@ -853,6 +878,16 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
email: 'codex-user@example.com',
|
email: 'codex-user@example.com',
|
||||||
status: 'abnormal',
|
status: 'abnormal',
|
||||||
|
quota: expect.objectContaining({
|
||||||
|
status: 'supported',
|
||||||
|
source: 'reverse_engineered',
|
||||||
|
lastLimitResetAt: '2026-03-17T13:00:00.000Z',
|
||||||
|
subscription: expect.objectContaining({
|
||||||
|
planType: 'team',
|
||||||
|
activeStart: '2026-03-01T00:00:00.000Z',
|
||||||
|
activeUntil: '2026-04-01T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
routeChannelCount: 1,
|
routeChannelCount: 1,
|
||||||
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
||||||
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
||||||
@@ -874,6 +909,96 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
expect(accounts).toEqual([]);
|
expect(accounts).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshes oauth quota snapshots and marks unsupported providers explicitly', async () => {
|
||||||
|
const codexSite = await db.insert(schema.sites).values({
|
||||||
|
name: 'ChatGPT Codex OAuth',
|
||||||
|
url: 'https://chatgpt.com/backend-api/codex',
|
||||||
|
platform: 'codex',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
const antigravitySite = await db.insert(schema.sites).values({
|
||||||
|
name: 'Antigravity OAuth',
|
||||||
|
url: 'https://example.com/antigravity',
|
||||||
|
platform: 'antigravity',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const codexAccount = await db.insert(schema.accounts).values({
|
||||||
|
siteId: codexSite.id,
|
||||||
|
username: 'codex-user@example.com',
|
||||||
|
accessToken: 'oauth-access-token',
|
||||||
|
status: 'active',
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'chatgpt-account-123',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
accountId: 'chatgpt-account-123',
|
||||||
|
email: 'codex-user@example.com',
|
||||||
|
planType: 'plus',
|
||||||
|
idToken: buildJwt({
|
||||||
|
email: 'codex-user@example.com',
|
||||||
|
'https://api.openai.com/auth': {
|
||||||
|
chatgpt_account_id: 'chatgpt-account-123',
|
||||||
|
chatgpt_plan_type: 'plus',
|
||||||
|
chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z',
|
||||||
|
chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const antigravityAccount = await db.insert(schema.accounts).values({
|
||||||
|
siteId: antigravitySite.id,
|
||||||
|
username: 'ag-user@example.com',
|
||||||
|
accessToken: 'oauth-access-token',
|
||||||
|
status: 'active',
|
||||||
|
oauthProvider: 'antigravity',
|
||||||
|
oauthAccountKey: 'ag-account-123',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'antigravity',
|
||||||
|
accountId: 'ag-account-123',
|
||||||
|
email: 'ag-user@example.com',
|
||||||
|
planType: 'pro',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const codexRefresh = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/oauth/connections/${codexAccount.id}/quota/refresh`,
|
||||||
|
});
|
||||||
|
expect(codexRefresh.statusCode).toBe(200);
|
||||||
|
expect(codexRefresh.json()).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
quota: expect.objectContaining({
|
||||||
|
status: 'supported',
|
||||||
|
subscription: expect.objectContaining({
|
||||||
|
planType: 'plus',
|
||||||
|
activeStart: '2026-03-01T00:00:00.000Z',
|
||||||
|
activeUntil: '2026-04-01T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const antigravityRefresh = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/oauth/connections/${antigravityAccount.id}/quota/refresh`,
|
||||||
|
});
|
||||||
|
expect(antigravityRefresh.statusCode).toBe(200);
|
||||||
|
expect(antigravityRefresh.json()).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
quota: expect.objectContaining({
|
||||||
|
status: 'unsupported',
|
||||||
|
providerMessage: 'current local CLIProxyAPI evidence does not expose official quota windows for antigravity oauth',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps multiple codex team workspaces with the same email as separate oauth connections', async () => {
|
it('keeps multiple codex team workspaces with the same email as separate oauth connections', async () => {
|
||||||
const buildTokenExchange = (accountId: string) => ({
|
const buildTokenExchange = (accountId: string) => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
handleOauthCallback,
|
handleOauthCallback,
|
||||||
listOauthConnections,
|
listOauthConnections,
|
||||||
listOauthProviders,
|
listOauthProviders,
|
||||||
|
refreshOauthConnectionQuota,
|
||||||
startOauthProviderFlow,
|
startOauthProviderFlow,
|
||||||
startOauthRebindFlow,
|
startOauthRebindFlow,
|
||||||
submitOauthManualCallback,
|
submitOauthManualCallback,
|
||||||
@@ -234,6 +235,22 @@ export async function oauthRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { accountId: string } }>(
|
||||||
|
'/api/oauth/connections/:accountId/quota/refresh',
|
||||||
|
{ preHandler: [limitOauthConnectionMutate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const accountId = parsePositiveInteger(request.params.accountId);
|
||||||
|
if (accountId === null) {
|
||||||
|
return reply.code(400).send({ message: 'invalid account id' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await refreshOauthConnectionQuota(accountId);
|
||||||
|
} catch (error: any) {
|
||||||
|
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>(
|
app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>(
|
||||||
'/api/oauth/callback/:provider',
|
'/api/oauth/callback/:provider',
|
||||||
{ preHandler: [limitOauthCallback] },
|
{ preHandler: [limitOauthCallback] },
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ describe('backupService', () => {
|
|||||||
username: 'roundtrip-user',
|
username: 'roundtrip-user',
|
||||||
accessToken: 'session-token',
|
accessToken: 'session-token',
|
||||||
apiToken: 'api-token',
|
apiToken: 'api-token',
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'roundtrip-account-key',
|
||||||
|
oauthProjectId: 'roundtrip-project-id',
|
||||||
balance: 12.3,
|
balance: 12.3,
|
||||||
balanceUsed: 4.5,
|
balanceUsed: 4.5,
|
||||||
quota: 99.9,
|
quota: 99.9,
|
||||||
@@ -141,6 +144,9 @@ describe('backupService', () => {
|
|||||||
|
|
||||||
expect(restoredAccount?.isPinned).toBe(true);
|
expect(restoredAccount?.isPinned).toBe(true);
|
||||||
expect(restoredAccount?.sortOrder).toBe(7);
|
expect(restoredAccount?.sortOrder).toBe(7);
|
||||||
|
expect(restoredAccount?.oauthProvider).toBe('codex');
|
||||||
|
expect(restoredAccount?.oauthAccountKey).toBe('roundtrip-account-key');
|
||||||
|
expect(restoredAccount?.oauthProjectId).toBe('roundtrip-project-id');
|
||||||
|
|
||||||
expect(restoredRoute?.displayName).toBe('gpt-route');
|
expect(restoredRoute?.displayName).toBe('gpt-route');
|
||||||
expect(restoredRoute?.displayIcon).toBe('icon-gpt');
|
expect(restoredRoute?.displayIcon).toBe('icon-gpt');
|
||||||
@@ -203,4 +209,80 @@ describe('backupService', () => {
|
|||||||
expect(accounts[0].username).toBe('legacy-user');
|
expect(accounts[0].username).toBe('legacy-user');
|
||||||
expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true);
|
expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('backfills oauth columns from extraConfig when importing older backups', async () => {
|
||||||
|
const payload = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
accounts: {
|
||||||
|
sites: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'codex-site',
|
||||||
|
url: 'https://codex.example.com',
|
||||||
|
platform: 'chatgpt-account',
|
||||||
|
proxyUrl: null,
|
||||||
|
status: 'active',
|
||||||
|
isPinned: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
apiKey: null,
|
||||||
|
createdAt: '2026-03-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-01T00:00:00.000Z',
|
||||||
|
externalCheckinUrl: null,
|
||||||
|
useSystemProxy: false,
|
||||||
|
globalWeight: 1,
|
||||||
|
customHeaders: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
siteId: 1,
|
||||||
|
username: 'oauth-user',
|
||||||
|
accessToken: 'oauth-access-token',
|
||||||
|
apiToken: null,
|
||||||
|
balance: 0,
|
||||||
|
balanceUsed: 0,
|
||||||
|
quota: 0,
|
||||||
|
unitCost: null,
|
||||||
|
valueScore: 0,
|
||||||
|
status: 'active',
|
||||||
|
isPinned: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
checkinEnabled: true,
|
||||||
|
lastCheckinAt: null,
|
||||||
|
lastBalanceRefresh: null,
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
accountId: 'oauth-user@example.com',
|
||||||
|
accountKey: 'oauth-user@example.com',
|
||||||
|
projectId: 'oauth-project-id',
|
||||||
|
refreshToken: 'oauth-refresh-token',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createdAt: '2026-03-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-01T00:00:00.000Z',
|
||||||
|
oauthProvider: null,
|
||||||
|
oauthAccountKey: null,
|
||||||
|
oauthProjectId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
accountTokens: [],
|
||||||
|
tokenRoutes: [],
|
||||||
|
routeChannels: [],
|
||||||
|
},
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
|
||||||
|
const result = await backupService.importBackup(payload);
|
||||||
|
|
||||||
|
expect(result.allImported).toBe(true);
|
||||||
|
expect(result.sections.accounts).toBe(true);
|
||||||
|
|
||||||
|
const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, 10)).get();
|
||||||
|
|
||||||
|
expect(restoredAccount?.oauthProvider).toBe('gemini-cli');
|
||||||
|
expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com');
|
||||||
|
expect(restoredAccount?.oauthProjectId).toBe('oauth-project-id');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { asc } from 'drizzle-orm';
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { upsertSetting } from '../db/upsertSetting.js';
|
import { upsertSetting } from '../db/upsertSetting.js';
|
||||||
|
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
|
||||||
|
|
||||||
const BACKUP_VERSION = '2.0';
|
const BACKUP_VERSION = '2.0';
|
||||||
|
|
||||||
@@ -104,6 +105,18 @@ function normalizeLegacyQuota(raw: unknown): number {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveImportedOauthColumns(row: Pick<AccountRow, 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId' | 'extraConfig'>) {
|
||||||
|
const oauth = getOauthInfoFromExtraConfig(row.extraConfig);
|
||||||
|
const oauthProvider = row.oauthProvider || oauth?.provider || null;
|
||||||
|
const oauthAccountKey = row.oauthAccountKey || oauth?.accountKey || oauth?.accountId || null;
|
||||||
|
const oauthProjectId = row.oauthProjectId || oauth?.projectId || null;
|
||||||
|
return {
|
||||||
|
oauthProvider,
|
||||||
|
oauthAccountKey,
|
||||||
|
oauthProjectId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLegacyPlatform(raw: string): string {
|
function normalizeLegacyPlatform(raw: string): string {
|
||||||
const value = raw.trim().toLowerCase();
|
const value = raw.trim().toLowerCase();
|
||||||
if (!value) return 'new-api';
|
if (!value) return 'new-api';
|
||||||
@@ -494,12 +507,16 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const row of section.accounts) {
|
for (const row of section.accounts) {
|
||||||
|
const oauthColumns = resolveImportedOauthColumns(row);
|
||||||
await tx.insert(schema.accounts).values({
|
await tx.insert(schema.accounts).values({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
accessToken: row.accessToken,
|
accessToken: row.accessToken,
|
||||||
apiToken: row.apiToken,
|
apiToken: row.apiToken,
|
||||||
|
oauthProvider: oauthColumns.oauthProvider,
|
||||||
|
oauthAccountKey: oauthColumns.oauthAccountKey,
|
||||||
|
oauthProjectId: oauthColumns.oauthProjectId,
|
||||||
balance: row.balance,
|
balance: row.balance,
|
||||||
balanceUsed: row.balanceUsed,
|
balanceUsed: row.balanceUsed,
|
||||||
quota: row.quota,
|
quota: row.quota,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { schema } from '../../db/index.js';
|
import { schema } from '../../db/index.js';
|
||||||
|
import type { OauthQuotaSnapshot } from './quotaTypes.js';
|
||||||
|
|
||||||
type ParsedOauthInfo = {
|
type ParsedOauthInfo = {
|
||||||
provider?: unknown;
|
provider?: unknown;
|
||||||
@@ -11,6 +12,7 @@ type ParsedOauthInfo = {
|
|||||||
refreshToken?: unknown;
|
refreshToken?: unknown;
|
||||||
idToken?: unknown;
|
idToken?: unknown;
|
||||||
providerData?: unknown;
|
providerData?: unknown;
|
||||||
|
quota?: unknown;
|
||||||
modelDiscoveryStatus?: unknown;
|
modelDiscoveryStatus?: unknown;
|
||||||
lastModelSyncAt?: unknown;
|
lastModelSyncAt?: unknown;
|
||||||
lastModelSyncError?: unknown;
|
lastModelSyncError?: unknown;
|
||||||
@@ -34,6 +36,7 @@ export type OauthInfo = {
|
|||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
idToken?: string;
|
idToken?: string;
|
||||||
providerData?: Record<string, unknown>;
|
providerData?: Record<string, unknown>;
|
||||||
|
quota?: OauthQuotaSnapshot;
|
||||||
modelDiscoveryStatus?: OauthModelDiscoveryStatus;
|
modelDiscoveryStatus?: OauthModelDiscoveryStatus;
|
||||||
lastModelSyncAt?: string;
|
lastModelSyncAt?: string;
|
||||||
lastModelSyncError?: string;
|
lastModelSyncError?: string;
|
||||||
@@ -88,6 +91,11 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asQuotaSnapshot(value: unknown): OauthQuotaSnapshot | undefined {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||||
|
return value as OauthQuotaSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | undefined {
|
function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | undefined {
|
||||||
if (typeof value !== 'string') return undefined;
|
if (typeof value !== 'string') return undefined;
|
||||||
const normalized = value.trim().toLowerCase();
|
const normalized = value.trim().toLowerCase();
|
||||||
@@ -113,6 +121,7 @@ export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthI
|
|||||||
refreshToken: asTrimmedString(parsed.refreshToken),
|
refreshToken: asTrimmedString(parsed.refreshToken),
|
||||||
idToken: asTrimmedString(parsed.idToken),
|
idToken: asTrimmedString(parsed.idToken),
|
||||||
providerData: asRecord(parsed.providerData),
|
providerData: asRecord(parsed.providerData),
|
||||||
|
quota: asQuotaSnapshot(parsed.quota),
|
||||||
modelDiscoveryStatus: asModelDiscoveryStatus(parsed.modelDiscoveryStatus),
|
modelDiscoveryStatus: asModelDiscoveryStatus(parsed.modelDiscoveryStatus),
|
||||||
lastModelSyncAt: asIsoDateTime(parsed.lastModelSyncAt),
|
lastModelSyncAt: asIsoDateTime(parsed.lastModelSyncAt),
|
||||||
lastModelSyncError: asTrimmedString(parsed.lastModelSyncError),
|
lastModelSyncError: asTrimmedString(parsed.lastModelSyncError),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from './providers.js';
|
} from './providers.js';
|
||||||
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
|
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
|
||||||
import { buildCodexOauthInfo } from './codexAccount.js';
|
import { buildCodexOauthInfo } from './codexAccount.js';
|
||||||
|
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
|
||||||
|
|
||||||
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
|
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
|
||||||
const MANUAL_CALLBACK_DELAY_MS = 15_000;
|
const MANUAL_CALLBACK_DELAY_MS = 15_000;
|
||||||
@@ -503,6 +504,7 @@ export async function listOauthConnections(options: {
|
|||||||
projectId: oauth.projectId,
|
projectId: oauth.projectId,
|
||||||
modelCount: models.length,
|
modelCount: models.length,
|
||||||
modelsPreview: models.slice(0, 10),
|
modelsPreview: models.slice(0, 10),
|
||||||
|
quota: buildQuotaSnapshotFromOauthInfo(oauth),
|
||||||
status,
|
status,
|
||||||
routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0,
|
routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0,
|
||||||
lastModelSyncAt: oauth.lastModelSyncAt,
|
lastModelSyncAt: oauth.lastModelSyncAt,
|
||||||
@@ -535,6 +537,11 @@ export async function deleteOauthConnection(accountId: number) {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshOauthConnectionQuota(accountId: number) {
|
||||||
|
const quota = await refreshOauthQuotaSnapshot(accountId);
|
||||||
|
return { success: true, quota };
|
||||||
|
}
|
||||||
|
|
||||||
export async function startOauthRebindFlow(accountId: number, requestOrigin?: string) {
|
export async function startOauthRebindFlow(accountId: number, requestOrigin?: string) {
|
||||||
const account = await db.select().from(schema.accounts)
|
const account = await db.select().from(schema.accounts)
|
||||||
.where(eq(schema.accounts.id, accountId))
|
.where(eq(schema.accounts.id, accountId))
|
||||||
|
|||||||
@@ -283,6 +283,33 @@ export type OAuthSessionInfo = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OAuthQuotaWindowInfo = {
|
||||||
|
supported: boolean;
|
||||||
|
limit?: number | null;
|
||||||
|
used?: number | null;
|
||||||
|
remaining?: number | null;
|
||||||
|
resetAt?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OAuthQuotaInfo = {
|
||||||
|
status: 'supported' | 'unsupported' | 'error';
|
||||||
|
source: 'official' | 'reverse_engineered';
|
||||||
|
lastSyncAt?: string | null;
|
||||||
|
lastError?: string | null;
|
||||||
|
providerMessage?: string | null;
|
||||||
|
subscription?: {
|
||||||
|
planType?: string | null;
|
||||||
|
activeStart?: string | null;
|
||||||
|
activeUntil?: string | null;
|
||||||
|
} | null;
|
||||||
|
windows: {
|
||||||
|
fiveHour: OAuthQuotaWindowInfo;
|
||||||
|
sevenDay: OAuthQuotaWindowInfo;
|
||||||
|
};
|
||||||
|
lastLimitResetAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type OAuthConnectionInfo = {
|
export type OAuthConnectionInfo = {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
siteId: number;
|
siteId: number;
|
||||||
@@ -295,6 +322,7 @@ export type OAuthConnectionInfo = {
|
|||||||
modelCount: number;
|
modelCount: number;
|
||||||
modelsPreview: string[];
|
modelsPreview: string[];
|
||||||
status: 'healthy' | 'abnormal';
|
status: 'healthy' | 'abnormal';
|
||||||
|
quota?: OAuthQuotaInfo | null;
|
||||||
routeChannelCount?: number;
|
routeChannelCount?: number;
|
||||||
lastModelSyncAt?: string | null;
|
lastModelSyncAt?: string | null;
|
||||||
lastModelSyncError?: string | null;
|
lastModelSyncError?: string | null;
|
||||||
@@ -432,6 +460,10 @@ export const api = {
|
|||||||
}) as Promise<{ success: true }>,
|
}) as Promise<{ success: true }>,
|
||||||
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
|
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
|
||||||
request(`/api/oauth/connections${buildQueryString(params)}`) as Promise<OAuthConnectionsResponse>,
|
request(`/api/oauth/connections${buildQueryString(params)}`) as Promise<OAuthConnectionsResponse>,
|
||||||
|
refreshOAuthConnectionQuota: (accountId: number) => request(`/api/oauth/connections/${accountId}/quota/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}) as Promise<{ success: true; quota: OAuthQuotaInfo }>,
|
||||||
rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, {
|
rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(()
|
|||||||
startOAuthProvider: vi.fn(),
|
startOAuthProvider: vi.fn(),
|
||||||
getOAuthSession: vi.fn(),
|
getOAuthSession: vi.fn(),
|
||||||
submitOAuthManualCallback: vi.fn(),
|
submitOAuthManualCallback: vi.fn(),
|
||||||
|
refreshOAuthConnectionQuota: vi.fn(),
|
||||||
rebindOAuthConnection: vi.fn(),
|
rebindOAuthConnection: vi.fn(),
|
||||||
deleteOAuthConnection: vi.fn(),
|
deleteOAuthConnection: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -684,6 +685,26 @@ describe('OAuthManagement page', () => {
|
|||||||
modelCount: 11,
|
modelCount: 11,
|
||||||
modelsPreview: ['gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex'],
|
modelsPreview: ['gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex'],
|
||||||
status: 'abnormal',
|
status: 'abnormal',
|
||||||
|
quota: {
|
||||||
|
status: 'supported',
|
||||||
|
source: 'reverse_engineered',
|
||||||
|
subscription: {
|
||||||
|
planType: 'team',
|
||||||
|
activeStart: '2026-03-01T00:00:00.000Z',
|
||||||
|
activeUntil: '2026-04-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
lastLimitResetAt: '2026-03-17T13:00:00.000Z',
|
||||||
|
windows: {
|
||||||
|
fiveHour: {
|
||||||
|
supported: false,
|
||||||
|
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
|
||||||
|
},
|
||||||
|
sevenDay: {
|
||||||
|
supported: false,
|
||||||
|
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
routeChannelCount: 1,
|
routeChannelCount: 1,
|
||||||
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
||||||
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
||||||
@@ -713,6 +734,12 @@ describe('OAuthManagement page', () => {
|
|||||||
expect(text).toContain('异常');
|
expect(text).toContain('异常');
|
||||||
expect(text).toContain('1 条路由');
|
expect(text).toContain('1 条路由');
|
||||||
expect(text).toContain('Codex 模型获取失败');
|
expect(text).toContain('Codex 模型获取失败');
|
||||||
|
expect(text).toContain('额度状态');
|
||||||
|
expect(text).toContain('team');
|
||||||
|
expect(text).toContain('2026-03-01T00:00:00.000Z');
|
||||||
|
expect(text).toContain('2026-04-01T00:00:00.000Z');
|
||||||
|
expect(text).toContain('2026-03-17T13:00:00.000Z');
|
||||||
|
expect(text).toContain('官方未提供');
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteButton = root!.root.find((node) => (
|
const deleteButton = root!.root.find((node) => (
|
||||||
@@ -735,4 +762,139 @@ describe('OAuthManagement page', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshes quota for a connection and reloads the connection list', async () => {
|
||||||
|
apiMock.getOAuthProviders.mockResolvedValue({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: 'codex',
|
||||||
|
label: 'Codex',
|
||||||
|
platform: 'codex',
|
||||||
|
enabled: true,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: false,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
apiMock.getOAuthConnections
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
accountId: 7,
|
||||||
|
provider: 'codex',
|
||||||
|
email: 'codex-user@example.com',
|
||||||
|
planType: 'team',
|
||||||
|
modelCount: 11,
|
||||||
|
modelsPreview: ['gpt-5.4'],
|
||||||
|
status: 'healthy',
|
||||||
|
quota: {
|
||||||
|
status: 'supported',
|
||||||
|
source: 'reverse_engineered',
|
||||||
|
windows: {
|
||||||
|
fiveHour: {
|
||||||
|
supported: false,
|
||||||
|
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
|
||||||
|
},
|
||||||
|
sevenDay: {
|
||||||
|
supported: false,
|
||||||
|
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
accountId: 7,
|
||||||
|
provider: 'codex',
|
||||||
|
email: 'codex-user@example.com',
|
||||||
|
planType: 'team',
|
||||||
|
modelCount: 11,
|
||||||
|
modelsPreview: ['gpt-5.4'],
|
||||||
|
status: 'healthy',
|
||||||
|
quota: {
|
||||||
|
status: 'supported',
|
||||||
|
source: 'reverse_engineered',
|
||||||
|
lastSyncAt: '2026-03-18T01:00:00.000Z',
|
||||||
|
windows: {
|
||||||
|
fiveHour: {
|
||||||
|
supported: false,
|
||||||
|
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
|
||||||
|
},
|
||||||
|
sevenDay: {
|
||||||
|
supported: false,
|
||||||
|
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
apiMock.refreshOAuthConnectionQuota.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
quota: {
|
||||||
|
status: 'supported',
|
||||||
|
source: 'reverse_engineered',
|
||||||
|
lastSyncAt: '2026-03-18T01:00:00.000Z',
|
||||||
|
windows: {
|
||||||
|
fiveHour: {
|
||||||
|
supported: false,
|
||||||
|
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
|
||||||
|
},
|
||||||
|
sevenDay: {
|
||||||
|
supported: false,
|
||||||
|
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<OAuthManagement />
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(collectText(root!.root)).toContain('刷新额度');
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshButton = root!.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('刷新额度')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await refreshButton.props.onClick();
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiMock.refreshOAuthConnectionQuota).toHaveBeenCalledWith(7);
|
||||||
|
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
|
||||||
|
expect(collectText(root!.root)).toContain('额度信息已刷新');
|
||||||
|
expect(collectText(root!.root)).toContain('2026-03-18T01:00:00.000Z');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { api, type OAuthConnectionInfo, type OAuthProviderInfo, type OAuthStartInstructions } from '../api.js';
|
import {
|
||||||
|
api,
|
||||||
|
type OAuthConnectionInfo,
|
||||||
|
type OAuthProviderInfo,
|
||||||
|
type OAuthQuotaInfo,
|
||||||
|
type OAuthQuotaWindowInfo,
|
||||||
|
type OAuthStartInstructions,
|
||||||
|
} from '../api.js';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 1500;
|
const POLL_INTERVAL_MS = 1500;
|
||||||
const CONNECTION_PAGE_LIMIT = 100;
|
const CONNECTION_PAGE_LIMIT = 100;
|
||||||
@@ -57,6 +64,35 @@ function resolveConnectionEmailLabel(connection: OAuthConnectionInfo): string {
|
|||||||
return email;
|
return email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveQuotaStatusLabel(status?: OAuthQuotaInfo['status']): string {
|
||||||
|
if (status === 'unsupported') return '不支持';
|
||||||
|
if (status === 'error') return '获取失败';
|
||||||
|
return '支持';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQuotaSourceLabel(source?: OAuthQuotaInfo['source']): string {
|
||||||
|
return source === 'official' ? '官方' : '本地信号';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQuotaWindowSummary(window?: OAuthQuotaWindowInfo | null): string {
|
||||||
|
if (!window || !window.supported) return '官方未提供';
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (typeof window.remaining === 'number') {
|
||||||
|
parts.push(`剩余 ${window.remaining}`);
|
||||||
|
}
|
||||||
|
if (typeof window.used === 'number' && typeof window.limit === 'number') {
|
||||||
|
parts.push(`已用 ${window.used} / ${window.limit}`);
|
||||||
|
} else if (typeof window.limit === 'number') {
|
||||||
|
parts.push(`总量 ${window.limit}`);
|
||||||
|
}
|
||||||
|
if (window.resetAt) {
|
||||||
|
parts.push(`重置 ${window.resetAt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(' · ') : '官方未提供';
|
||||||
|
}
|
||||||
|
|
||||||
function renderCodeBlock(value: string) {
|
function renderCodeBlock(value: string) {
|
||||||
return (
|
return (
|
||||||
<code style={{
|
<code style={{
|
||||||
@@ -264,6 +300,20 @@ export default function OAuthManagement() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefreshQuota = async (accountId: number) => {
|
||||||
|
const actionKey = `quota:${accountId}`;
|
||||||
|
setActionLoadingKey(actionKey);
|
||||||
|
try {
|
||||||
|
await api.refreshOAuthConnectionQuota(accountId);
|
||||||
|
setSessionMessage('额度信息已刷新');
|
||||||
|
await loadConnections();
|
||||||
|
} catch (error: any) {
|
||||||
|
setSessionMessage(error?.message || '刷新额度失败');
|
||||||
|
} finally {
|
||||||
|
setActionLoadingKey('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -416,9 +466,11 @@ export default function OAuthManagement() {
|
|||||||
<div style={{ display: 'grid', gap: 12 }}>
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
{connections.map((connection) => {
|
{connections.map((connection) => {
|
||||||
const rebindActionKey = `start:${connection.provider}:${connection.accountId}`;
|
const rebindActionKey = `start:${connection.provider}:${connection.accountId}`;
|
||||||
|
const refreshQuotaActionKey = `quota:${connection.accountId}`;
|
||||||
const deleteActionKey = `delete:${connection.accountId}`;
|
const deleteActionKey = `delete:${connection.accountId}`;
|
||||||
const primaryTitle = resolveConnectionPrimaryTitle(connection);
|
const primaryTitle = resolveConnectionPrimaryTitle(connection);
|
||||||
const emailLabel = resolveConnectionEmailLabel(connection);
|
const emailLabel = resolveConnectionEmailLabel(connection);
|
||||||
|
const quota = connection.quota;
|
||||||
return (
|
return (
|
||||||
<div key={connection.accountId} style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 14 }}>
|
<div key={connection.accountId} style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 14 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
||||||
@@ -460,8 +512,72 @@ export default function OAuthManagement() {
|
|||||||
{connection.modelsPreview.join(', ')}
|
{connection.modelsPreview.join(', ')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{quota && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12,
|
||||||
|
paddingTop: 12,
|
||||||
|
borderTop: '1px dashed var(--color-border)',
|
||||||
|
display: 'grid',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>额度状态</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
{resolveQuotaStatusLabel(quota.status)} · {resolveQuotaSourceLabel(quota.source)}
|
||||||
|
{quota.lastSyncAt ? ` · 最近刷新: ${quota.lastSyncAt}` : ''}
|
||||||
|
</div>
|
||||||
|
{quota.subscription?.planType && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
订阅类型: {quota.subscription.planType}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{quota.subscription?.activeStart && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
订阅开始: {quota.subscription.activeStart}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{quota.subscription?.activeUntil && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
订阅到期: {quota.subscription.activeUntil}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{quota.lastLimitResetAt && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
最近重置提示: {quota.lastLimitResetAt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
5 小时窗口: {resolveQuotaWindowSummary(quota.windows?.fiveHour)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
7 天窗口: {resolveQuotaWindowSummary(quota.windows?.sevenDay)}
|
||||||
|
</div>
|
||||||
|
{quota.providerMessage && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||||
|
{quota.providerMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{quota.lastError && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-danger)' }}>
|
||||||
|
{quota.lastError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => handleRefreshQuota(connection.accountId)}
|
||||||
|
disabled={
|
||||||
|
actionLoadingKey === rebindActionKey
|
||||||
|
|| actionLoadingKey === refreshQuotaActionKey
|
||||||
|
|| actionLoadingKey === deleteActionKey
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{actionLoadingKey === refreshQuotaActionKey ? '刷新中...' : '刷新额度'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
@@ -479,7 +595,11 @@ export default function OAuthManagement() {
|
|||||||
},
|
},
|
||||||
connection.accountId,
|
connection.accountId,
|
||||||
)}
|
)}
|
||||||
disabled={actionLoadingKey === rebindActionKey || actionLoadingKey === deleteActionKey}
|
disabled={
|
||||||
|
actionLoadingKey === rebindActionKey
|
||||||
|
|| actionLoadingKey === refreshQuotaActionKey
|
||||||
|
|| actionLoadingKey === deleteActionKey
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{actionLoadingKey === rebindActionKey ? '启动中...' : '重新授权'}
|
{actionLoadingKey === rebindActionKey ? '启动中...' : '重新授权'}
|
||||||
</button>
|
</button>
|
||||||
@@ -487,7 +607,11 @@ export default function OAuthManagement() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() => handleDelete(connection.accountId)}
|
onClick={() => handleDelete(connection.accountId)}
|
||||||
disabled={actionLoadingKey === rebindActionKey || actionLoadingKey === deleteActionKey}
|
disabled={
|
||||||
|
actionLoadingKey === rebindActionKey
|
||||||
|
|| actionLoadingKey === refreshQuotaActionKey
|
||||||
|
|| actionLoadingKey === deleteActionKey
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{actionLoadingKey === deleteActionKey ? '删除中...' : '删除连接'}
|
{actionLoadingKey === deleteActionKey ? '删除中...' : '删除连接'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user