Skip to content

Commit 25f3ded

Browse files
lidge-junWibias
andcommitted
feat(providers): Re-authenticate for OAuth and Codex accounts (#171)
Adds Re-authenticate CTAs for OAuth multi-account and Codex pool accounts, with amber/needs-setup status when the active account is stale. Hardens reauth so OAuth GUI sends accountId + reauth, binds credentials to the selected slot, and refuses success when needsReauth remains. Codex pool reauth requires ChatGPT id or pool email anchors. Squash-merged from Wibias:feature/provider-reauth (fc6acb8). Co-Authored-By: Wibias <37517432+Wibias@users.noreply.github.com>
1 parent 6035608 commit 25f3ded

29 files changed

Lines changed: 1168 additions & 151 deletions

gui/src/components/AddCodexAccountModal.tsx

Lines changed: 113 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,38 @@ import { IconGlobe, IconLink } from "../icons";
33
import { useT } from "../i18n";
44

55
export default function AddCodexAccountModal({
6-
apiBase, onClose, onAdded,
6+
apiBase, onClose, onAdded, reauthAccountId,
77
}: {
88
apiBase: string;
99
onClose: () => void;
1010
onAdded: () => void;
11+
reauthAccountId?: string;
1112
}) {
1213
const t = useT();
13-
const aliveRef = useRef(true);
14-
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
15-
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
16-
const flowRef = useRef<string | null>(null);
17-
useEffect(() => () => {
18-
aliveRef.current = false;
19-
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
20-
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
21-
}, []);
22-
23-
const [step, setStep] = useState<"pick" | "oauth-waiting">("pick");
14+
const [step, setStep] = useState<"pick" | "oauth-waiting">(reauthAccountId ? "oauth-waiting" : "pick");
2415
const [id, setId] = useState("");
2516
const [error, setError] = useState("");
2617
const [authUrl, setAuthUrl] = useState("");
2718
const [copied, setCopied] = useState(false);
2819

20+
const aliveRef = useRef(true);
21+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
22+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
23+
const flowRef = useRef<string | null>(null);
24+
/** Ensures reauth auto-start runs once per account id, even if startOAuth identity changes. */
25+
const startedReauthRef = useRef<string | null>(null);
26+
const onAddedRef = useRef(onAdded);
27+
const onCloseRef = useRef(onClose);
2928
const previousFocusRef = useRef<HTMLElement | null>(null);
3029
const dialogRef = useRef<HTMLDivElement>(null);
30+
31+
useEffect(() => {
32+
onAddedRef.current = onAdded;
33+
}, [onAdded]);
34+
useEffect(() => {
35+
onCloseRef.current = onClose;
36+
}, [onClose]);
37+
3138
const stopPolling = useCallback(() => {
3239
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
3340
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
@@ -46,10 +53,96 @@ export default function AddCodexAccountModal({
4653
}).catch(() => {});
4754
}, [apiBase, stopPolling]);
4855

56+
useEffect(() => () => {
57+
aliveRef.current = false;
58+
const flowId = flowRef.current;
59+
flowRef.current = null;
60+
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
61+
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
62+
// Cancel in-flight OAuth so a remounted modal cannot race a stale chatgpt scratch slot.
63+
if (flowId) {
64+
void fetch(`${apiBase}/api/codex-auth/login/cancel`, {
65+
method: "POST",
66+
headers: { "Content-Type": "application/json" },
67+
body: JSON.stringify({ flowId }),
68+
}).catch(() => {});
69+
}
70+
}, [apiBase]);
71+
4972
const closeModal = useCallback(() => {
5073
if (step === "oauth-waiting") void cancelLogin();
51-
onClose();
52-
}, [step, onClose, cancelLogin]);
74+
onCloseRef.current();
75+
}, [step, cancelLogin]);
76+
77+
const startOAuth = useCallback(async (requestedId?: string) => {
78+
setError("");
79+
try {
80+
const accountId = reauthAccountId ?? requestedId?.trim() ?? "";
81+
const resp = await fetch(`${apiBase}/api/codex-auth/login`, {
82+
method: "POST",
83+
headers: { "Content-Type": "application/json" },
84+
body: JSON.stringify(
85+
reauthAccountId
86+
? { id: reauthAccountId, reauth: true }
87+
: (accountId ? { id: accountId } : {}),
88+
),
89+
});
90+
const data = await resp.json() as { url?: string; flowId?: string; error?: string; status?: string };
91+
if (resp.status === 409) {
92+
setError(t("codexAuth.oauthAlreadyInProgress"));
93+
return;
94+
}
95+
if (data.url) {
96+
flowRef.current = data.flowId ?? null;
97+
setAuthUrl(data.url);
98+
setStep("oauth-waiting");
99+
stopPolling();
100+
const fid = data.flowId ?? "";
101+
const reauthQuery = reauthAccountId ? "&reauth=1" : "";
102+
const statusUrl = fid
103+
? `${apiBase}/api/codex-auth/login-status?flowId=${encodeURIComponent(fid)}${accountId ? `&accountId=${encodeURIComponent(accountId)}` : ""}${reauthQuery}`
104+
: `${apiBase}/api/codex-auth/login-status`;
105+
pollRef.current = setInterval(async () => {
106+
try {
107+
const st = await fetch(statusUrl).then(r => r.json()) as { status: string; error?: string };
108+
if (st.status === "done") {
109+
stopPolling();
110+
flowRef.current = null;
111+
onAddedRef.current();
112+
onCloseRef.current();
113+
} else if (st.status === "error" || st.status === "expired") {
114+
stopPolling();
115+
flowRef.current = null;
116+
if (aliveRef.current) {
117+
if (!reauthAccountId) setStep("pick");
118+
setError(st.error ?? "Login failed");
119+
}
120+
}
121+
} catch { /* ignore network errors during polling */ }
122+
}, 2000);
123+
timeoutRef.current = setTimeout(() => {
124+
if (pollRef.current) {
125+
void cancelLogin();
126+
if (aliveRef.current) {
127+
if (!reauthAccountId) setStep("pick");
128+
setError(t("modal.loginTimeout"));
129+
}
130+
}
131+
}, 300_000);
132+
}
133+
if (data.error && !data.url) setError(data.error);
134+
} catch (e) { setError(String(e)); }
135+
}, [apiBase, cancelLogin, reauthAccountId, stopPolling, t]);
136+
137+
useEffect(() => {
138+
if (!reauthAccountId) {
139+
startedReauthRef.current = null;
140+
return;
141+
}
142+
if (startedReauthRef.current === reauthAccountId) return;
143+
startedReauthRef.current = reauthAccountId;
144+
void startOAuth();
145+
}, [reauthAccountId, startOAuth]);
53146

54147
const copyLoginLink = async () => {
55148
if (!authUrl) return;
@@ -59,8 +152,8 @@ export default function AddCodexAccountModal({
59152
} else {
60153
const input = document.createElement("textarea");
61154
input.value = authUrl;
62-
input.style.position = "fixed";
63155
input.style.opacity = "0";
156+
input.style.position = "fixed";
64157
document.body.appendChild(input);
65158
input.select();
66159
document.execCommand("copy");
@@ -94,8 +187,10 @@ export default function AddCodexAccountModal({
94187
return () => window.removeEventListener("keydown", onKey);
95188
}, [closeModal]);
96189

190+
const dialogLabel = reauthAccountId ? t("codexAuth.reauthenticate") : t("codexAuth.addTitle");
191+
97192
return (
98-
<div role="dialog" aria-modal="true" aria-label={t("codexAuth.addTitle")} className="modal-overlay" onClick={closeModal}>
193+
<div role="dialog" aria-modal="true" aria-label={dialogLabel} className="modal-overlay" onClick={closeModal}>
99194
<div ref={dialogRef} className="modal-card" onClick={e => e.stopPropagation()} style={{ maxWidth: 440 }}>
100195
{step === "pick" && (
101196
<>
@@ -111,54 +206,7 @@ export default function AddCodexAccountModal({
111206
style={{ marginBottom: 12 }}
112207
/>
113208

114-
<button className="list-row" onClick={async () => {
115-
setError("");
116-
try {
117-
const requestedId = id.trim();
118-
const resp = await fetch(`${apiBase}/api/codex-auth/login`, {
119-
method: "POST",
120-
headers: { "Content-Type": "application/json" },
121-
body: JSON.stringify(requestedId ? { id: requestedId } : {}),
122-
});
123-
const data = await resp.json() as { url?: string; flowId?: string; error?: string; status?: string };
124-
if (resp.status === 409) {
125-
setError(t("codexAuth.oauthAlreadyInProgress"));
126-
return;
127-
}
128-
if (data.url) {
129-
flowRef.current = data.flowId ?? null;
130-
setAuthUrl(data.url);
131-
setStep("oauth-waiting");
132-
stopPolling();
133-
const fid = data.flowId ?? "";
134-
const statusUrl = fid
135-
? `${apiBase}/api/codex-auth/login-status?flowId=${encodeURIComponent(fid)}${requestedId ? `&accountId=${encodeURIComponent(requestedId)}` : ""}`
136-
: `${apiBase}/api/codex-auth/login-status`;
137-
pollRef.current = setInterval(async () => {
138-
try {
139-
const st = await fetch(statusUrl).then(r => r.json()) as { status: string; error?: string };
140-
if (st.status === "done") {
141-
stopPolling();
142-
flowRef.current = null;
143-
onAdded();
144-
onClose();
145-
} else if (st.status === "error" || st.status === "expired") {
146-
stopPolling();
147-
flowRef.current = null;
148-
if (aliveRef.current) { setStep("pick"); setError(st.error ?? "Login failed"); }
149-
}
150-
} catch { /* ignore network errors during polling */ }
151-
}, 2000);
152-
timeoutRef.current = setTimeout(() => {
153-
if (pollRef.current) {
154-
void cancelLogin();
155-
if (aliveRef.current) { setStep("pick"); setError(t("modal.loginTimeout")); }
156-
}
157-
}, 300_000);
158-
}
159-
if (data.error && !data.url) setError(data.error);
160-
} catch (e) { setError(String(e)); }
161-
}} style={{ marginBottom: 8 }}>
209+
<button className="list-row" onClick={() => void startOAuth(id)} style={{ marginBottom: 8 }}>
162210
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
163211
<IconGlobe width={20} />
164212
<div>
@@ -178,7 +226,7 @@ export default function AddCodexAccountModal({
178226

179227
{step === "oauth-waiting" && (
180228
<>
181-
<h3 style={{ marginBottom: 4 }}>{t("codexAuth.oauthLogin")}</h3>
229+
<h3 style={{ marginBottom: 4 }}>{reauthAccountId ? t("codexAuth.reauthenticate") : t("codexAuth.oauthLogin")}</h3>
182230
<p className="modal-desc">{t("codexAuth.oauthWaiting")}</p>
183231
<button className="btn btn-ghost" onClick={copyLoginLink} disabled={!authUrl} style={{ width: "100%", justifyContent: "center", marginTop: 12 }}>
184232
<IconLink width={14} /> {copied ? t("codexAuth.loginLinkCopied") : t("codexAuth.copyLoginLink")}

gui/src/components/CodexAccountPool.tsx

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,20 @@ export interface CodexAccountEntry {
2121
* (the Codex Auth page passes its mode banner); `embedded` (WP090) omits page
2222
* chrome — currently a no-op stub reserved for the Providers workspace.
2323
*/
24-
export default function CodexAccountPool({ apiBase, accountModeState = null, banner = null, embedded = false }: {
24+
export default function CodexAccountPool({ apiBase, accountModeState = null, banner = null, embedded = false, onActiveNeedsReauthChange }: {
2525
apiBase: string;
2626
accountModeState?: CodexAccountModeState | null;
2727
banner?: ReactNode;
2828
embedded?: boolean;
29+
onActiveNeedsReauthChange?: (needs: boolean) => void;
2930
}) {
3031
const t = useT();
3132
const [accounts, setAccounts] = useState<CodexAccountEntry[]>([]);
3233
const [activeId, setActiveId] = useState<string | null>(null);
3334
const [autoThreshold, setAutoThreshold] = useState(80);
3435
const [confirm, setConfirm] = useState<CodexAccountEntry | null>(null);
3536
const [showAdd, setShowAdd] = useState(false);
37+
const [reauthId, setReauthId] = useState<string | null>(null);
3638
const [toast, setToast] = useState("");
3739
const [toastError, setToastError] = useState(false);
3840
const [refreshingQuota, setRefreshingQuota] = useState(false);
@@ -70,14 +72,49 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
7072
const timeout = window.setTimeout(() => {
7173
void load();
7274
}, 0);
75+
// Pause background refresh while an add/reauth modal is open so unstable
76+
// parent re-renders cannot stack with a live OAuth flow.
77+
if (showAdd) {
78+
return () => window.clearTimeout(timeout);
79+
}
7380
const iv = window.setInterval(() => {
7481
void load();
7582
}, 30_000);
7683
return () => {
7784
window.clearTimeout(timeout);
7885
window.clearInterval(iv);
7986
};
80-
}, [load]);
87+
}, [load, showAdd]);
88+
89+
const activePoolAccount = activeId && activeId !== "__main__"
90+
? accounts.find(a => a.id === activeId)
91+
: null;
92+
const mainAccount = accounts.find(a => a.isMain);
93+
const activeNeedsReauth = activePoolAccount
94+
? Boolean(activePoolAccount.needsReauth)
95+
: Boolean(mainAccount?.needsReauth);
96+
97+
useEffect(() => {
98+
onActiveNeedsReauthChange?.(activeNeedsReauth);
99+
}, [activeNeedsReauth, onActiveNeedsReauthChange]);
100+
101+
const openReauth = useCallback((id: string) => {
102+
setReauthId(id);
103+
setShowAdd(true);
104+
}, []);
105+
106+
const closeAddModal = useCallback(() => {
107+
setShowAdd(false);
108+
setReauthId(null);
109+
}, []);
110+
111+
const handleAccountAdded = useCallback(() => {
112+
void load();
113+
setToast(t("codexAuth.accountAdded"));
114+
setToastError(false);
115+
setTimeout(() => setToast(""), 5000);
116+
closeAddModal();
117+
}, [closeAddModal, load, t]);
81118

82119
const setActive = async (id: string | null) => {
83120
if (switchingId) return;
@@ -236,10 +273,11 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
236273

237274
<div className={`card ${isMainActive ? "card-active" : ""}`} style={{ marginBottom: 12 }}>
238275
<div className="card-head">
239-
<span className="dot dot-green" />
276+
<span className={`dot ${main?.needsReauth ? "dot-amber" : "dot-green"}`} />
240277
<strong>{t("codexAuth.mainAccount")}</strong>
241278
<span className="card-badges">
242279
{main && <TicketBadge t={t} account={{ ...main, id: "__main__" } as CodexAccountEntry} onClick={() => openResetPopup({ ...main, id: "__main__" } as CodexAccountEntry)} />}
280+
{main?.needsReauth && <span className="badge badge-amber">{t("codexAuth.needsReauth")}</span>}
243281
<span className={`badge ${isMainActive ? "badge-primary" : "badge-muted"}`}>
244282
{isMainActive
245283
? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")
@@ -254,7 +292,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
254292
<span className="card-right"><IconLock width={14} /> {t("codexAuth.appLogin")}</span>
255293
</div>
256294
<div className="card-sub">{main?.email ?? "Codex App login"}{main?.plan ? ` · ${main.plan}` : ""}</div>
257-
{main?.quota && <QuotaBars quota={main.quota} plan={main.plan} threshold={autoThreshold} t={t} />}
295+
{main?.needsReauth
296+
? <div className="card-sub faint">{t("codexAuth.mainTokenExpired")}</div>
297+
: main?.quota && <QuotaBars quota={main.quota} plan={main.plan} threshold={autoThreshold} t={t} />}
258298
</div>
259299

260300
<div className="section-sep">
@@ -265,6 +305,15 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
265305
</button>
266306
</div>
267307

308+
{activePoolAccount?.needsReauth && (
309+
<div className="notice-warn" style={{ marginBottom: 12, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
310+
<span><IconAlert width={14} /> {t("codexAuth.tokenExpired")}</span>
311+
<button type="button" className="btn btn-primary btn-sm" onClick={() => openReauth(activePoolAccount.id)}>
312+
{t("codexAuth.reauthenticate")}
313+
</button>
314+
</div>
315+
)}
316+
268317
{pool.length === 0 && <EmptyState title={t("codexAuth.noPool")} />}
269318

270319
{pool.map(a => (
@@ -287,6 +336,11 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
287336
{switchActionLabel}
288337
</button>
289338
)}
339+
{a.needsReauth && (
340+
<button type="button" className="btn btn-primary btn-sm" onClick={() => openReauth(a.id)}>
341+
{t("codexAuth.reauthenticate")}
342+
</button>
343+
)}
290344
<button
291345
className="btn-icon btn-icon-danger card-right"
292346
aria-label={`${t("common.remove")}${a.email}`}
@@ -402,8 +456,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
402456
{showAdd && (
403457
<AddCodexAccountModal
404458
apiBase={apiBase}
405-
onClose={() => setShowAdd(false)}
406-
onAdded={() => { load(); setToast(t("codexAuth.accountAdded")); setTimeout(() => setToast(""), 5000); }}
459+
reauthAccountId={reauthId ?? undefined}
460+
onClose={closeAddModal}
461+
onAdded={handleAccountAdded}
407462
/>
408463
)}
409464
</div>

0 commit comments

Comments
 (0)