Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
433 changes: 55 additions & 378 deletions gui/src/components/AddCodexAccountModal.tsx

Large diffs are not rendered by default.

328 changes: 73 additions & 255 deletions gui/src/components/CodexAccountPool.tsx

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions gui/src/components/add-codex-account-pick-step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { IconGlobe } from "../icons";
import { useT } from "../i18n";

export function AddCodexAccountPickStep({
id,
error,
onIdChange,
onStartOAuth,
onClose,
}: {
id: string;
error: string;
onIdChange: (value: string) => void;
onStartOAuth: () => void;
onClose: () => void;
}) {
const t = useT();

return (
<>
<h3 style={{ marginBottom: 4 }}>{t("codexAuth.addTitle")}</h3>
<p className="modal-desc">{t("codexAuth.addPickDesc")}</p>

<label className="field-label" htmlFor="codex-account-id-input">{t("codexAuth.addIdLabel")}</label>
<input
id="codex-account-id-input"
className="input"
placeholder={t("codexAuth.addIdPlaceholder")}
value={id}
onChange={e => onIdChange(e.target.value)}
style={{ marginBottom: 12 }}
/>

<button type="button" className="list-row" onClick={onStartOAuth} style={{ marginBottom: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<IconGlobe width={20} />
<div>
<div className="title">{t("codexAuth.oauthLogin")}</div>
<div className="sub">{t("codexAuth.oauthDesc")}</div>
</div>
</div>
</button>

{error && <div className="notice notice-err" style={{ marginTop: 8 }}>{error}</div>}

<button type="button" className="btn btn-ghost" onClick={onClose} style={{ width: "100%" }}>
{t("codexAuth.cancel")}
</button>
</>
);
}
74 changes: 74 additions & 0 deletions gui/src/components/add-codex-account-reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export type AddCodexAccountStep = "pick" | "oauth-waiting";
export type ManualCodeState = "idle" | "submitting" | "waiting";
export type StatusTone = "ok" | "warn";

export interface AddCodexAccountUiState {
step: AddCodexAccountStep;
id: string;
error: string;
authUrl: string;
copied: boolean;
manualCode: string;
manualCodeState: ManualCodeState;
statusNotice: string;
statusTone: StatusTone;
flowId: string | null;
}

export const initialAddCodexAccountUiState = (reauthAccountId?: string): AddCodexAccountUiState => ({
step: reauthAccountId ? "oauth-waiting" : "pick",
id: "",
error: "",
authUrl: "",
copied: false,
manualCode: "",
manualCodeState: "idle",
statusNotice: "",
statusTone: "ok",
flowId: null,
});

export type AddCodexAccountUiAction =
| { type: "set-step"; step: AddCodexAccountStep }
| { type: "set-id"; id: string }
| { type: "set-error"; error: string }
| { type: "set-auth-url"; authUrl: string }
| { type: "set-copied"; copied: boolean }
| { type: "set-manual-code"; manualCode: string }
| { type: "set-manual-code-state"; manualCodeState: ManualCodeState }
| { type: "set-status-notice"; statusNotice: string; statusTone?: StatusTone }
| { type: "set-flow-id"; flowId: string | null }
| { type: "clear-manual-code" }
| { type: "reset-oauth-start" }
| { type: "oauth-code-submitted" };

export function addCodexAccountUiReducer(state: AddCodexAccountUiState, action: AddCodexAccountUiAction): AddCodexAccountUiState {
switch (action.type) {
case "set-step":
return { ...state, step: action.step };
case "set-id":
return { ...state, id: action.id };
case "set-error":
return { ...state, error: action.error };
case "set-auth-url":
return { ...state, authUrl: action.authUrl };
case "set-copied":
return { ...state, copied: action.copied };
case "set-manual-code":
return { ...state, manualCode: action.manualCode };
case "set-manual-code-state":
return { ...state, manualCodeState: action.manualCodeState };
case "set-status-notice":
return { ...state, statusNotice: action.statusNotice, statusTone: action.statusTone ?? state.statusTone };
case "set-flow-id":
return { ...state, flowId: action.flowId };
case "clear-manual-code":
return { ...state, manualCode: "", manualCodeState: "idle", statusNotice: "", statusTone: "ok" };
case "reset-oauth-start":
return { ...state, error: "", statusNotice: "", statusTone: "ok", flowId: null };
case "oauth-code-submitted":
return { ...state, error: "", manualCode: "", manualCodeState: "waiting", statusTone: "ok", statusNotice: "" };
default:
return state;
}
}
95 changes: 95 additions & 0 deletions gui/src/components/add-codex-account-waiting-step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { IconLink } from "../icons";
import { useT } from "../i18n";
import type { StatusTone } from "./add-codex-account-reducer";

export function AddCodexAccountWaitingStep({
reauthAccountId,
authUrl,
copied,
manualCode,
manualCodeBusy,
manualCodeWaiting,
statusNotice,
statusTone,
flowId,
error,
onCopyLoginLink,
onManualCodeChange,
onSubmitManualCode,
onClose,
}: {
reauthAccountId?: string;
authUrl: string;
copied: boolean;
manualCode: string;
manualCodeBusy: boolean;
manualCodeWaiting: boolean;
statusNotice: string;
statusTone: StatusTone;
flowId: string | null;
error: string;
onCopyLoginLink: () => void;
onManualCodeChange: (value: string) => void;
onSubmitManualCode: () => void;
onClose: () => void;
}) {
const t = useT();

return (
<>
<h3 style={{ marginBottom: 4 }}>{reauthAccountId ? t("codexAuth.reauthenticate") : t("codexAuth.oauthLogin")}</h3>
<p className="modal-desc">{t("codexAuth.oauthWaiting")}</p>
<button type="button" className="btn btn-ghost" onClick={onCopyLoginLink} disabled={!authUrl} style={{ width: "100%", justifyContent: "center", marginTop: 12 }}>
<IconLink width={14} /> {copied ? t("codexAuth.loginLinkCopied") : t("codexAuth.copyLoginLink")}
</button>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 12 }}>
<div className="muted text-label">{t("prov.pasteRedirectHint")}</div>
<div style={{ display: "flex", gap: 8 }}>
<input
type="text"
autoComplete="off"
spellCheck={false}
value={manualCode}
onChange={e => onManualCodeChange(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
onSubmitManualCode();
}
}}
placeholder={t("prov.pasteRedirect")}
aria-label={t("prov.pasteRedirect")}
disabled={manualCodeBusy || manualCodeWaiting}
className="input text-label"
style={{ flex: 1 }}
/>
<button
className="btn btn-ghost"
type="button"
disabled={manualCodeBusy || manualCodeWaiting || !manualCode.trim() || !flowId}
onClick={onSubmitManualCode}
>
{manualCodeBusy ? t("codexAuth.oauthSubmittingCode") : t("prov.pasteSubmit")}
</button>
</div>
</div>
{statusNotice && (
<div
className={statusTone === "warn" ? "notice-warn" : "notice notice-ok"}
role="status"
aria-live="polite"
style={{ marginTop: 12 }}
>
{statusNotice}
</div>
)}
{error && <div className="notice notice-err" style={{ marginTop: 12 }}>{error}</div>}
<div style={{ textAlign: "center", padding: "24px 0" }}>
<span className="spin" style={{ width: 24, height: 24 }} />
</div>
<button type="button" className="btn btn-ghost" onClick={onClose} style={{ width: "100%" }}>
{t("codexAuth.cancel")}
</button>
</>
);
}
98 changes: 98 additions & 0 deletions gui/src/components/codex-account-pool-cards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useT } from "../i18n";
import { IconAlert, IconX } from "../icons";
import type { CodexAccountEntry } from "./codex-account-pool-types";
import type { CodexAccountModeState } from "../codex-multi-state";
import QuotaBars from "./QuotaBars";
import { CodexTicketBadge } from "./codex-account-pool-helpers";

export function CodexAccountPoolCards({
pool,
activeId,
accountModeState,
switchActionLabel,
threshold,
onOpenReset,
onSwitch,
onReauth,
onEditAlias,
onRemove,
}: {
pool: CodexAccountEntry[];
activeId: string | null;
accountModeState: CodexAccountModeState | null;
switchActionLabel: string;
threshold: number;
onOpenReset: (account: CodexAccountEntry) => void;
onSwitch: (account: CodexAccountEntry) => void;
onReauth: (id: string) => void;
onEditAlias: (account: CodexAccountEntry) => void;
onRemove: (id: string) => void;
}) {
const t = useT();
const isNext = (id: string) => activeId === id;

return (
<>
{pool.map(a => (
<div key={a.id} className={`card ${isNext(a.id) ? "card-active" : ""}`} style={{ marginBottom: 8 }}>
<div className="card-head">
<span className={`dot ${a.needsReauth ? "dot-amber" : isNext(a.id) ? "dot-blue" : "dot-muted"}`} />
<strong>{a.alias ?? a.email}</strong>
<span className="card-badges">
{a.plan && <span className="badge badge-green">{a.plan}</span>}
<CodexTicketBadge t={t} account={a} onClick={() => onOpenReset(a)} />
{a.needsReauth && <span className="badge badge-amber">{t("codexAuth.needsReauth")}</span>}
{isNext(a.id) && !a.needsReauth && (
<span className="badge badge-primary">
{t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")}
</span>
)}
</span>
{!isNext(a.id) && !a.needsReauth && (
<button type="button" className="btn btn-ghost btn-sm codex-account-switch" onClick={() => onSwitch(a)}>
{switchActionLabel}
</button>
)}
{a.needsReauth && (
<button type="button" className="btn btn-primary btn-sm" onClick={() => onReauth(a.id)}>
{t("codexAuth.reauthenticate")}
</button>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void onEditAlias(a)}>
{t("prov.editAlias")}
</button>
<button
type="button"
className="btn-icon btn-icon-danger card-right"
aria-label={`${t("common.remove")} — ${a.email}`}
title={`${t("common.remove")} — ${a.email}`}
onClick={e => { e.stopPropagation(); void onRemove(a.id); }}
>
<IconX width={14} />
</button>
</div>
<div className="card-sub">{a.email}{a.plan ? ` · ${a.plan}` : ""} · {t("prov.accountId")}: {a.id}</div>
{a.needsReauth
? <div className="card-sub faint">{t("codexAuth.tokenExpired")}</div>
: <QuotaBars quota={a.quota} plan={a.plan} threshold={threshold} t={t} />}
</div>
))}
</>
);
}

export function CodexAccountPoolReauthBanner({
onReauth,
}: {
onReauth: () => void;
}) {
const t = useT();
return (
<div className="notice-warn" style={{ marginBottom: 12, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<span><IconAlert width={14} /> {t("codexAuth.tokenExpired")}</span>
<button type="button" className="btn btn-primary btn-sm" onClick={onReauth}>
{t("codexAuth.reauthenticate")}
</button>
</div>
);
}
47 changes: 47 additions & 0 deletions gui/src/components/codex-account-pool-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { TFn } from "../i18n";
import { readJsonIfOk } from "../fetch-json";

function remainingCreditsToast(
t: TFn,
remaining: number | undefined,
): string {
if (remaining === undefined) return t("codexAuth.resetSuccessGeneric");
return t("codexAuth.resetSuccess", { remaining: String(remaining) });
}

export async function redeemResetCredit(
apiBase: string,
accountId: string,
t: TFn,
load: (refresh?: boolean) => Promise<boolean>,
): Promise<{ ok: boolean; toast?: string; close?: boolean }> {
try {
const resp = await fetch(`${apiBase}/api/codex-auth/reset-credits/consume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountId }),
});
const result = await readJsonIfOk<{ code: string; remaining?: number }>(resp);
if (!result) return { ok: false, toast: t("codexAuth.resetError") };
if (result.code === "reset" || result.code === "already_redeemed") {
await load(true);
// Authoritative remaining comes from the management endpoint (refreshed quota).
// Never invent a decrement from a stale modal snapshot.
const remaining =
typeof result.remaining === "number" && Number.isFinite(result.remaining)
? Math.max(0, result.remaining)
: undefined;
return {
ok: true,
close: true,
toast: remainingCreditsToast(t, remaining),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const key = result.code === "nothing_to_reset" ? "codexAuth.resetNothingToReset"
: result.code === "no_credit" ? "codexAuth.resetNoCredit"
: "codexAuth.resetError";
return { ok: false, close: true, toast: t(key) };
} catch {
return { ok: false, toast: t("codexAuth.resetError") };
}
}
Loading
Loading