From 5883e57d734223c795a89b33e9570e26a14f4be5 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 21:58:01 +0900 Subject: [PATCH] Settings: show codex account email + subscription expiry (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract email and chatgpt_subscription_last_checked from codex JWT in addition to plan_type and subscription_active_until. - Settings → 계정 row now displays: email · plan badge · expiry badge with active/soon/expired color coding (green/amber/red). - Fix codex/.codex-accounts directory scan picking up sibling dirs like "1.bak-..." and double-counting an index (require dirname to be fully numeric). Motivation: rotation was landing on accounts whose Pro/Team subscription had silently expired, causing OpenAI to reject upper-tier models with a misleading "model not supported" error. Surface expiry in the UI so the user can see at a glance which accounts need renewal. Co-authored-by: Claude Opus 4.7 --- apps/dashboard/src/App.tsx | 152 ++++++++++++++++++++++++---------- apps/dashboard/src/api.ts | 2 + apps/dashboard/src/styles.css | 59 ++++++++++++- src/settings-store.ts | 12 +++ 4 files changed, 176 insertions(+), 49 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 3a71d89..162485c 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1234,6 +1234,34 @@ function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) { ); } +function formatExpiry( + iso: string | null, +): { label: string; cls: string } | null { + if (!iso) return null; + const dt = new Date(iso); + if (Number.isNaN(dt.getTime())) return null; + const days = (dt.getTime() - Date.now()) / 86400000; + const dateStr = dt.toLocaleDateString('ko-KR', { + year: '2-digit', + month: '2-digit', + day: '2-digit', + }); + if (days < 0) { + const ago = Math.ceil(-days); + return { label: `${dateStr} 만료 (${ago}일 전)`, cls: 'is-expired' }; + } + if (days < 7) { + return { + label: `${dateStr}까지 (${Math.floor(days)}일)`, + cls: 'is-soon', + }; + } + return { + label: `${dateStr}까지 (${Math.floor(days)}일)`, + cls: 'is-active', + }; +} + function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { const [data, setData] = useState<{ claude: ClaudeAccountSummary[]; @@ -1309,29 +1337,39 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {

계정 없음

) : ( )}
@@ -1359,29 +1397,51 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {

계정 없음

) : (
    - {data.codex.map((acc) => ( -
  • - #{acc.index} - - {acc.planType ?? 'unknown'} - {acc.subscriptionUntil - ? ` · ${acc.subscriptionUntil}까지` - : ''} - - {acc.index > 0 ? ( - - ) : ( - 기본 - )} -
  • - ))} + {data.codex.map((acc) => { + const expiry = formatExpiry(acc.subscriptionUntil); + return ( +
  • +
    + #{acc.index} + {acc.email ? ( + + {acc.email} + + ) : null} + + {acc.planType ?? 'unknown'} + + {expiry ? ( + + {expiry.label} + + ) : null} +
    + {acc.index > 0 ? ( + + ) : ( + 기본 + )} +
  • + ); + })}
)}

diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index 01cf643..a62416d 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -355,8 +355,10 @@ export interface ClaudeAccountSummary { export interface CodexAccountSummary { index: number; accountId: string | null; + email: string | null; planType: string | null; subscriptionUntil: string | null; + subscriptionLastChecked: string | null; exists: boolean; } diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 9f726b3..4824dd1 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -864,21 +864,74 @@ button:disabled { .settings-account-row { display: grid; - grid-template-columns: 40px minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; - padding: 6px 8px; + padding: 8px 10px; border: 1px solid var(--panel-border); - border-radius: 6px; + border-radius: 8px; background: rgba(255, 255, 255, 0.02); } +.settings-account-main { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + align-items: center; + min-width: 0; +} + .settings-account-tag { + flex: none; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11px; color: var(--muted); } +.settings-account-email { + font-size: 13px; + font-weight: 600; + color: var(--bg-ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.settings-account-plan { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + color: var(--muted); +} + +.settings-account-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + font-variant-numeric: tabular-nums; +} + +.settings-account-badge.is-active { + background: rgba(80, 180, 130, 0.14); + color: #6dc89a; +} + +.settings-account-badge.is-soon { + background: rgba(214, 170, 60, 0.16); + color: #e0bc5b; +} + +.settings-account-badge.is-expired { + background: rgba(224, 100, 75, 0.16); + color: var(--status-critical, #e0644b); +} + .settings-account-meta { font-size: 12px; color: var(--bg-ink); diff --git a/src/settings-store.ts b/src/settings-store.ts index 95826e3..11954fb 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -27,8 +27,10 @@ export interface ClaudeAccountSummary { export interface CodexAccountSummary { index: number; accountId: string | null; + email: string | null; planType: string | null; subscriptionUntil: string | null; + subscriptionLastChecked: string | null; exists: boolean; } @@ -105,8 +107,10 @@ function readCodexAccount(index: number): CodexAccountSummary | null { tokens?: { id_token?: string; access_token?: string }; }>(file); let accountId: string | null = null; + let email: string | null = null; let planType: string | null = null; let subscriptionUntil: string | null = null; + let subscriptionLastChecked: string | null = null; const idToken = data?.tokens?.id_token; if (idToken && typeof idToken === 'string') { const parts = idToken.split('.'); @@ -116,6 +120,7 @@ function readCodexAccount(index: number): CodexAccountSummary | null { Buffer.from(parts[1], 'base64').toString('utf-8'), ) as Record; if (typeof payload.sub === 'string') accountId = payload.sub; + if (typeof payload.email === 'string') email = payload.email; const auth = payload['https://api.openai.com/auth'] as | Record | undefined; @@ -126,6 +131,9 @@ function readCodexAccount(index: number): CodexAccountSummary | null { if (typeof auth.chatgpt_subscription_active_until === 'string') { subscriptionUntil = auth.chatgpt_subscription_active_until; } + if (typeof auth.chatgpt_subscription_last_checked === 'string') { + subscriptionLastChecked = auth.chatgpt_subscription_last_checked; + } } } catch { /* ignore parse errors */ @@ -135,8 +143,10 @@ function readCodexAccount(index: number): CodexAccountSummary | null { return { index, accountId, + email, planType, subscriptionUntil, + subscriptionLastChecked, exists: true, }; } @@ -149,6 +159,7 @@ export function listClaudeAccounts(): ClaudeAccountSummary[] { if (fs.existsSync(dir)) { const entries = fs.readdirSync(dir); const indices = entries + .filter((e) => /^\d+$/.test(e)) .map((e) => Number.parseInt(e, 10)) .filter((n) => Number.isFinite(n) && n >= 1) .sort((a, b) => a - b); @@ -168,6 +179,7 @@ export function listCodexAccounts(): CodexAccountSummary[] { if (fs.existsSync(dir)) { const entries = fs.readdirSync(dir); const indices = entries + .filter((e) => /^\d+$/.test(e)) .map((e) => Number.parseInt(e, 10)) .filter((n) => Number.isFinite(n) && n >= 1) .sort((a, b) => a - b);