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

@@ -36,10 +36,13 @@ import {
fetchDashboardData,
fetchFastMode,
fetchModelConfig,
refreshAllCodexAccounts as refreshAllCodexAccountsApi,
refreshCodexAccount as refreshCodexAccountApi,
runInboxAction,
runServiceAction,
runScheduledTaskAction,
sendRoomMessage,
setCurrentCodexAccount as setCurrentCodexAccountApi,
updateFastMode,
updateModels,
updateScheduledTask,
@@ -1355,8 +1358,10 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
const [data, setData] = useState<{
claude: ClaudeAccountSummary[];
codex: CodexAccountSummary[];
codexCurrentIndex?: number;
} | null>(null);
const [busy, setBusy] = useState(false);
const [perRowBusy, setPerRowBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [tokenInput, setTokenInput] = useState('');
@@ -1413,6 +1418,50 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
}
}
async function handleCodexRefresh(index: number) {
setPerRowBusy(`refresh:${index}`);
setError(null);
try {
await refreshCodexAccountApi(index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPerRowBusy(null);
}
}
async function handleRefreshAllCodex() {
setBusy(true);
setError(null);
try {
const result = await refreshAllCodexAccountsApi();
await refresh();
if (result.failed.length > 0) {
setError(
`일부 갱신 실패: ${result.failed.map((f) => `#${f.index}`).join(', ')}`,
);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleSwitchCodex(index: number) {
setPerRowBusy(`switch:${index}`);
setError(null);
try {
await setCurrentCodexAccountApi(index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPerRowBusy(null);
}
}
return (
<section className="settings-section">
<h3></h3>
@@ -1479,7 +1528,17 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
</div>
<div className="settings-account-group">
<h4>Codex</h4>
<div className="settings-account-group-head">
<h4>Codex</h4>
<button
className="settings-secondary"
disabled={busy}
onClick={() => void handleRefreshAllCodex()}
type="button"
>
</button>
</div>
{!data ? (
<p className="settings-hint">{busy ? '불러오는 중…' : '없음'}</p>
) : data.codex.length === 0 ? (
@@ -1488,10 +1547,18 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
<ul className="settings-account-list">
{data.codex.map((acc) => {
const expiry = formatExpiry(acc.subscriptionUntil);
const isActive = data.codexCurrentIndex === acc.index;
const refreshing = perRowBusy === `refresh:${acc.index}`;
const switching = perRowBusy === `switch:${acc.index}`;
return (
<li key={acc.index} className="settings-account-row">
<li
key={acc.index}
className={`settings-account-row${isActive ? ' is-active-account' : ''}`}
>
<div className="settings-account-main">
<span className="settings-account-tag">#{acc.index}</span>
<span className="settings-account-tag">
{isActive ? '●' : ''}#{acc.index}
</span>
{acc.email ? (
<span
className="settings-account-email"
@@ -1516,26 +1583,48 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
</span>
) : null}
</div>
{acc.index > 0 ? (
<div className="settings-account-actions">
<button
className="settings-delete"
disabled={busy}
onClick={() => void handleDelete('codex', acc.index)}
className="settings-secondary"
disabled={busy || perRowBusy !== null}
onClick={() => void handleCodexRefresh(acc.index)}
title="OAuth 토큰을 다시 받아 구독 상태를 갱신합니다"
type="button"
>
{refreshing ? '갱신중…' : '갱신'}
</button>
) : (
<span className="settings-account-default"></span>
)}
{!isActive ? (
<button
className="settings-secondary"
disabled={busy || perRowBusy !== null}
onClick={() => void handleSwitchCodex(acc.index)}
title="이 계정으로 즉시 전환합니다 (다음 codex 호출부터 적용)"
type="button"
>
{switching ? '전환중…' : '전환'}
</button>
) : (
<span className="settings-account-default"></span>
)}
{acc.index > 0 ? (
<button
className="settings-delete"
disabled={busy || perRowBusy !== null}
onClick={() => void handleDelete('codex', acc.index)}
type="button"
>
</button>
) : null}
</div>
</li>
);
})}
</ul>
)}
<p className="settings-hint">
Codex <code>codex login</code> CLI로
~/.codex-accounts/N/ auth.json .
OAuth 6 . plan /
.
</p>
</div>