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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user