Codex accounts: 6h auto-refresh, manual refresh, manual switch (#48)

Auto refresh
- New refreshCodexAccount(index) calls https://auth.openai.com/oauth/token
  with grant_type=refresh_token and persists rotated tokens back to
  ~/.codex-accounts/{N}/auth.json. JWT's subscription_active_until
  reflects the latest plan state OpenAI hands back.
- startCodexAccountRefreshLoop() runs every 6h (first run delayed 60s
  after boot to keep startup snappy). Hooked into main()/shutdown()
  alongside the existing claude token refresh loop.

Manual controls (Settings → 계정 → Codex)
- "갱신" button per row: forces an immediate refresh for that account.
- "전체 갱신" button: refreshes all codex accounts in sequence.
- "전환" button: writes data/codex-rotation-state.json so the next codex
  spawn picks the chosen account. Active account marked with a green-
  bordered card and "사용중" badge.

Endpoints
- POST /api/settings/accounts/codex/{i}/refresh
- POST /api/settings/accounts/codex/refresh-all
- PUT  /api/settings/accounts/codex/current  { index }
- GET  /api/settings/accounts now also returns codexCurrentIndex.

Why
JWTs cache subscription state at issue-time. When a Pro plan lapses or
a user upgrades/downgrades, the dashboard kept showing stale data until
the user logged in again. Periodic refresh + explicit "갱신" button
keeps the displayed state honest, and the manual switch unblocks the
user when rotation lands on a known-bad account.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Eyejoker
2026-04-27 22:23:06 +09:00
committed by GitHub
parent 2f7e09e194
commit c2a6889567
7 changed files with 437 additions and 13 deletions

View File

@@ -381,6 +381,7 @@ export interface FastModeSnapshot {
export async function fetchAccounts(): Promise<{
claude: ClaudeAccountSummary[];
codex: CodexAccountSummary[];
codexCurrentIndex?: number;
}> {
return fetchJson('/api/settings/accounts');
}
@@ -417,6 +418,64 @@ export async function updateModels(
return (await response.json()) as ModelConfigSnapshot;
}
export async function refreshCodexAccount(
index: number,
): Promise<CodexAccountSummary> {
const response = await fetch(
`/api/settings/accounts/codex/${index}/refresh`,
{ method: 'POST' },
);
if (!response.ok) {
let msg = `refresh failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
const json = (await response.json()) as { account: CodexAccountSummary };
return json.account;
}
export async function refreshAllCodexAccounts(): Promise<{
refreshed: number[];
failed: Array<{ index: number; error: string }>;
}> {
const response = await fetch('/api/settings/accounts/codex/refresh-all', {
method: 'POST',
});
if (!response.ok) {
throw new Error(`refresh-all failed: ${response.status}`);
}
return (await response.json()) as {
refreshed: number[];
failed: Array<{ index: number; error: string }>;
};
}
export async function setCurrentCodexAccount(
index: number,
): Promise<{ codexCurrentIndex: number }> {
const response = await fetch('/api/settings/accounts/codex/current', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ index }),
});
if (!response.ok) {
let msg = `switch failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return (await response.json()) as { codexCurrentIndex: number };
}
export async function fetchFastMode(): Promise<FastModeSnapshot> {
return fetchJson('/api/settings/fast-mode');
}