Settings: show codex account email + subscription expiry (#45)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }) {
|
function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
|
||||||
const [data, setData] = useState<{
|
const [data, setData] = useState<{
|
||||||
claude: ClaudeAccountSummary[];
|
claude: ClaudeAccountSummary[];
|
||||||
@@ -1309,29 +1337,39 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
|
|||||||
<p className="settings-hint">계정 없음</p>
|
<p className="settings-hint">계정 없음</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="settings-account-list">
|
<ul className="settings-account-list">
|
||||||
{data.claude.map((acc) => (
|
{data.claude.map((acc) => {
|
||||||
<li key={acc.index} className="settings-account-row">
|
const expiry = formatExpiry(
|
||||||
<span className="settings-account-tag">#{acc.index}</span>
|
acc.expiresAt ? new Date(acc.expiresAt).toISOString() : null,
|
||||||
<span className="settings-account-meta">
|
);
|
||||||
{acc.subscriptionType ?? 'unknown'}
|
return (
|
||||||
{acc.expiresAt
|
<li key={acc.index} className="settings-account-row">
|
||||||
? ` · 만료 ${new Date(acc.expiresAt).toLocaleDateString()}`
|
<div className="settings-account-main">
|
||||||
: ''}
|
<span className="settings-account-tag">#{acc.index}</span>
|
||||||
</span>
|
<span className="settings-account-email">
|
||||||
{acc.index > 0 ? (
|
{acc.subscriptionType ?? 'unknown'}
|
||||||
<button
|
{acc.rateLimitTier ? ` · ${acc.rateLimitTier}` : ''}
|
||||||
className="settings-delete"
|
</span>
|
||||||
disabled={busy}
|
{expiry ? (
|
||||||
onClick={() => void handleDelete('claude', acc.index)}
|
<span className={`settings-account-badge ${expiry.cls}`}>
|
||||||
type="button"
|
{expiry.label}
|
||||||
>
|
</span>
|
||||||
삭제
|
) : null}
|
||||||
</button>
|
</div>
|
||||||
) : (
|
{acc.index > 0 ? (
|
||||||
<span className="settings-account-default">기본</span>
|
<button
|
||||||
)}
|
className="settings-delete"
|
||||||
</li>
|
disabled={busy}
|
||||||
))}
|
onClick={() => void handleDelete('claude', acc.index)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="settings-account-default">기본</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<div className="settings-add-token">
|
<div className="settings-add-token">
|
||||||
@@ -1359,29 +1397,51 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
|
|||||||
<p className="settings-hint">계정 없음</p>
|
<p className="settings-hint">계정 없음</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="settings-account-list">
|
<ul className="settings-account-list">
|
||||||
{data.codex.map((acc) => (
|
{data.codex.map((acc) => {
|
||||||
<li key={acc.index} className="settings-account-row">
|
const expiry = formatExpiry(acc.subscriptionUntil);
|
||||||
<span className="settings-account-tag">#{acc.index}</span>
|
return (
|
||||||
<span className="settings-account-meta">
|
<li key={acc.index} className="settings-account-row">
|
||||||
{acc.planType ?? 'unknown'}
|
<div className="settings-account-main">
|
||||||
{acc.subscriptionUntil
|
<span className="settings-account-tag">#{acc.index}</span>
|
||||||
? ` · ${acc.subscriptionUntil}까지`
|
{acc.email ? (
|
||||||
: ''}
|
<span
|
||||||
</span>
|
className="settings-account-email"
|
||||||
{acc.index > 0 ? (
|
title={acc.email}
|
||||||
<button
|
>
|
||||||
className="settings-delete"
|
{acc.email}
|
||||||
disabled={busy}
|
</span>
|
||||||
onClick={() => void handleDelete('codex', acc.index)}
|
) : null}
|
||||||
type="button"
|
<span className="settings-account-plan">
|
||||||
>
|
{acc.planType ?? 'unknown'}
|
||||||
삭제
|
</span>
|
||||||
</button>
|
{expiry ? (
|
||||||
) : (
|
<span
|
||||||
<span className="settings-account-default">기본</span>
|
className={`settings-account-badge ${expiry.cls}`}
|
||||||
)}
|
title={
|
||||||
</li>
|
acc.subscriptionLastChecked
|
||||||
))}
|
? `last checked: ${acc.subscriptionLastChecked.slice(0, 10)}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{expiry.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{acc.index > 0 ? (
|
||||||
|
<button
|
||||||
|
className="settings-delete"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void handleDelete('codex', acc.index)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="settings-account-default">기본</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<p className="settings-hint">
|
<p className="settings-hint">
|
||||||
|
|||||||
@@ -355,8 +355,10 @@ export interface ClaudeAccountSummary {
|
|||||||
export interface CodexAccountSummary {
|
export interface CodexAccountSummary {
|
||||||
index: number;
|
index: number;
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
|
email: string | null;
|
||||||
planType: string | null;
|
planType: string | null;
|
||||||
subscriptionUntil: string | null;
|
subscriptionUntil: string | null;
|
||||||
|
subscriptionLastChecked: string | null;
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -864,21 +864,74 @@ button:disabled {
|
|||||||
|
|
||||||
.settings-account-row {
|
.settings-account-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 6px 8px;
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--panel-border);
|
border: 1px solid var(--panel-border);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
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 {
|
.settings-account-tag {
|
||||||
|
flex: none;
|
||||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--muted);
|
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 {
|
.settings-account-meta {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--bg-ink);
|
color: var(--bg-ink);
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ export interface ClaudeAccountSummary {
|
|||||||
export interface CodexAccountSummary {
|
export interface CodexAccountSummary {
|
||||||
index: number;
|
index: number;
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
|
email: string | null;
|
||||||
planType: string | null;
|
planType: string | null;
|
||||||
subscriptionUntil: string | null;
|
subscriptionUntil: string | null;
|
||||||
|
subscriptionLastChecked: string | null;
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +107,10 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
|||||||
tokens?: { id_token?: string; access_token?: string };
|
tokens?: { id_token?: string; access_token?: string };
|
||||||
}>(file);
|
}>(file);
|
||||||
let accountId: string | null = null;
|
let accountId: string | null = null;
|
||||||
|
let email: string | null = null;
|
||||||
let planType: string | null = null;
|
let planType: string | null = null;
|
||||||
let subscriptionUntil: string | null = null;
|
let subscriptionUntil: string | null = null;
|
||||||
|
let subscriptionLastChecked: string | null = null;
|
||||||
const idToken = data?.tokens?.id_token;
|
const idToken = data?.tokens?.id_token;
|
||||||
if (idToken && typeof idToken === 'string') {
|
if (idToken && typeof idToken === 'string') {
|
||||||
const parts = idToken.split('.');
|
const parts = idToken.split('.');
|
||||||
@@ -116,6 +120,7 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
|||||||
Buffer.from(parts[1], 'base64').toString('utf-8'),
|
Buffer.from(parts[1], 'base64').toString('utf-8'),
|
||||||
) as Record<string, unknown>;
|
) as Record<string, unknown>;
|
||||||
if (typeof payload.sub === 'string') accountId = payload.sub;
|
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
|
const auth = payload['https://api.openai.com/auth'] as
|
||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -126,6 +131,9 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
|||||||
if (typeof auth.chatgpt_subscription_active_until === 'string') {
|
if (typeof auth.chatgpt_subscription_active_until === 'string') {
|
||||||
subscriptionUntil = auth.chatgpt_subscription_active_until;
|
subscriptionUntil = auth.chatgpt_subscription_active_until;
|
||||||
}
|
}
|
||||||
|
if (typeof auth.chatgpt_subscription_last_checked === 'string') {
|
||||||
|
subscriptionLastChecked = auth.chatgpt_subscription_last_checked;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore parse errors */
|
/* ignore parse errors */
|
||||||
@@ -135,8 +143,10 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
|||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
accountId,
|
accountId,
|
||||||
|
email,
|
||||||
planType,
|
planType,
|
||||||
subscriptionUntil,
|
subscriptionUntil,
|
||||||
|
subscriptionLastChecked,
|
||||||
exists: true,
|
exists: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -149,6 +159,7 @@ export function listClaudeAccounts(): ClaudeAccountSummary[] {
|
|||||||
if (fs.existsSync(dir)) {
|
if (fs.existsSync(dir)) {
|
||||||
const entries = fs.readdirSync(dir);
|
const entries = fs.readdirSync(dir);
|
||||||
const indices = entries
|
const indices = entries
|
||||||
|
.filter((e) => /^\d+$/.test(e))
|
||||||
.map((e) => Number.parseInt(e, 10))
|
.map((e) => Number.parseInt(e, 10))
|
||||||
.filter((n) => Number.isFinite(n) && n >= 1)
|
.filter((n) => Number.isFinite(n) && n >= 1)
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b);
|
||||||
@@ -168,6 +179,7 @@ export function listCodexAccounts(): CodexAccountSummary[] {
|
|||||||
if (fs.existsSync(dir)) {
|
if (fs.existsSync(dir)) {
|
||||||
const entries = fs.readdirSync(dir);
|
const entries = fs.readdirSync(dir);
|
||||||
const indices = entries
|
const indices = entries
|
||||||
|
.filter((e) => /^\d+$/.test(e))
|
||||||
.map((e) => Number.parseInt(e, 10))
|
.map((e) => Number.parseInt(e, 10))
|
||||||
.filter((n) => Number.isFinite(n) && n >= 1)
|
.filter((n) => Number.isFinite(n) && n >= 1)
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b);
|
||||||
|
|||||||
Reference in New Issue
Block a user