diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index efd84a35b..a07632b32 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -1,12 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { IconX, IconLock, IconKey, IconExternal } from "../icons"; +import { useEffect, useMemo, useReducer, useRef } from "react"; +import { IconX } from "../icons"; import { useT } from "../i18n"; +import { useKeyedClientResource } from "../client-resource"; import { buildProviderPostBody, codexPresetDescriptionKey, isReservedCodexForwardPreset, type ProviderPayload, - type ProviderPayloadForm, } from "../provider-payload"; import { oauthTosRisk } from "../oauth-tos-risk"; import OAuthTosWarningModal from "./OAuthTosWarningModal"; @@ -14,14 +14,18 @@ import ProviderCatalog from "./provider-catalog/ProviderCatalog"; import type { AccountLoginRow, AccountLoginStatus } from "./provider-catalog/ProviderCatalog"; import type { CatalogPreset } from "./provider-catalog/provider-presets"; import { baseUrlForChoice, matchChoiceId, resolvedBaseUrlForChoice } from "../base-url-choice"; +import { AddProviderOAuthPane } from "./add-provider-oauth-pane"; +import { AddProviderFormPane } from "./add-provider-form-pane"; +import { useAddProviderOAuth } from "./use-add-provider-oauth"; +import { + addProviderModalReducer, + createInitialAddProviderState, +} from "./add-provider-modal-reducer"; export type ProviderConfig = ProviderPayload; -/** Local alias — the DTO type is owned by provider-catalog/provider-presets.ts. */ type Preset = CatalogPreset; -type FormState = ProviderPayloadForm; - export default function AddProviderModal({ apiBase, existingNames, onClose, onAdded, initialTier, initialCustom = false, accountRows, accountStatus, accountBusy, onAccountLogin, onAccountCancelLogin, onAccountLogout, onOpen, @@ -30,9 +34,7 @@ export default function AddProviderModal({ existingNames: string[]; onClose: () => void; onAdded: (name: string) => void; - /** Opening catalog tab (workspace empty-state tiles deep-link here). */ initialTier?: "accounts" | "free" | "paid"; - /** Skip the catalog and open the custom-provider form immediately. */ initialCustom?: boolean; accountRows?: AccountLoginRow[]; accountStatus?: Record; @@ -46,35 +48,57 @@ export default function AddProviderModal({ const fallbackPresets = useMemo(() => [ { id: "custom", label: t("modal.customProvider"), adapter: "openai-chat", baseUrl: "", auth: "key" }, ], [t]); - const [preset, setPreset] = useState( - initialCustom ? { id: "custom", label: t("modal.customProvider"), adapter: "openai-chat", baseUrl: "", auth: "key" } : null, + const [state, dispatch] = useReducer( + addProviderModalReducer, + initialCustom, + (custom) => createInitialAddProviderState(custom, t("modal.customProvider")), ); - const [form, setForm] = useState( - initialCustom - ? { name: "", adapter: "openai-chat", baseUrl: "", authMode: "key", apiKey: "", defaultModel: "", allowPrivateNetwork: false } - : null, - ); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(""); - const [oauthSupported, setOauthSupported] = useState([]); - const [oauthBusy, setOauthBusy] = useState(false); - const [oauthMsg, setOauthMsg] = useState(""); - const [oauthMsgTone, setOauthMsgTone] = useState<"ok" | "warn">("ok"); - const [manualCode, setManualCode] = useState(""); - const [manualCodeBusy, setManualCodeBusy] = useState(false); - const [manualCodeMsg, setManualCodeMsg] = useState(""); - const [manualCodeOk, setManualCodeOk] = useState(true); - const [presets, setPresets] = useState(fallbackPresets); - const [presetsLoading, setPresetsLoading] = useState(true); - const [usageRank, setUsageRank] = useState>({}); - const [endpointChoice, setEndpointChoice] = useState("custom"); - const [oauthTosPending, setOauthTosPending] = useState(null); const aliveRef = useRef(true); - const loadedPresetsRef = useRef(false); const previousFocusRef = useRef(null); const dialogRef = useRef(null); - // Refresh OAuth status once when the modal opens (not when fetchOauth identity changes). + const oauthPoll = useKeyedClientResource( + `add-provider-oauth:${apiBase}`, + [apiBase], + async (signal) => { + const res = await fetch(`${apiBase}/api/oauth/providers`, { signal }); + if (!res.ok) return [] as string[]; + const data = await res.json() as { providers?: string[] }; + return data.providers ?? []; + }, + ); + const presetsPoll = useKeyedClientResource( + `add-provider-presets:${apiBase}`, + [apiBase], + async (signal) => { + const res = await fetch(`${apiBase}/api/provider-presets`, { signal }); + if (!res.ok) throw new Error(String(res.status)); + const data = await res.json() as { providers?: Preset[] }; + return Array.isArray(data.providers) && data.providers.length > 0 ? data.providers : null; + }, + ); + const usagePoll = useKeyedClientResource( + `add-provider-usage:${apiBase}`, + [apiBase], + async (signal) => { + const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal }); + if (!res.ok) return {} as Record; + const data = await res.json() as { providers?: Array<{ provider: string; requests: number }> }; + const rank: Record = {}; + for (const row of data.providers ?? []) rank[row.provider] = row.requests; + return rank; + }, + ); + + const oauthSupported = oauthPoll.data ?? []; + const presets = presetsPoll.data ?? fallbackPresets; + const presetsLoading = presetsPoll.loading; + const usageRank = usagePoll.data ?? {}; + const { + preset, form, saving, error, oauthBusy, oauthMsg, oauthMsgTone, + manualCode, manualCodeBusy, manualCodeMsg, manualCodeOk, endpointChoice, oauthTosPending, + } = state; + useEffect(() => { aliveRef.current = true; previousFocusRef.current = document.activeElement as HTMLElement | null; @@ -82,7 +106,7 @@ export default function AddProviderModal({ const dialog = dialogRef.current; if (dialog) { const focusable = dialog.querySelector( - "input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])" + "input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])", ); if (focusable) focusable.focus(); } @@ -92,39 +116,14 @@ export default function AddProviderModal({ }; // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only open hook }, []); + useEffect(() => { const onKey = (e: KeyboardEvent) => { - // Child ToS warning owns Escape while it is open. if (e.key === "Escape" && !oauthTosPending) onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose, oauthTosPending]); - useEffect(() => { - fetch(`${apiBase}/api/oauth/providers`).then(r => r.json()).then(d => setOauthSupported(d.providers ?? [])).catch(() => {}); - }, [apiBase]); - useEffect(() => { - fetch(`${apiBase}/api/provider-presets`).then(r => r.json()).then((d: { providers?: Preset[] }) => { - if (Array.isArray(d.providers) && d.providers.length > 0) { - loadedPresetsRef.current = true; - setPresets(d.providers); - } - }).catch(() => {}).finally(() => setPresetsLoading(false)); - }, [apiBase]); - // Usage rank drives the catalog's default row order (most-used first). - useEffect(() => { - fetch(`${apiBase}/api/usage?range=30d`).then(r => r.json()).then((d: { - providers?: Array<{ provider: string; requests: number }>; - }) => { - const rank: Record = {}; - for (const row of d.providers ?? []) rank[row.provider] = row.requests; - setUsageRank(rank); - }).catch(() => {}); - }, [apiBase]); - // Keep the custom fallback label in sync when language changes and API presets never loaded. - useEffect(() => { - if (!loadedPresetsRef.current) setPresets(fallbackPresets); - }, [fallbackPresets]); const presetDescription = (candidate: Preset): string | undefined => { const key = codexPresetDescriptionKey(candidate); @@ -132,38 +131,23 @@ export default function AddProviderModal({ }; const choosePreset = (p: Preset) => { - setPreset(p); const choiceId = matchChoiceId(p.baseUrlChoices, p.baseUrl); - setEndpointChoice(choiceId); - setForm({ - name: p.id === "custom" ? "" : p.id, - adapter: p.adapter, - baseUrl: p.baseUrlChoices?.length - ? baseUrlForChoice(p.baseUrlChoices, choiceId, p.baseUrl) - : p.baseUrl, - authMode: p.auth, - apiKey: "", - defaultModel: p.defaultModel ?? "", - allowPrivateNetwork: false, + dispatch({ + type: "choose-preset", + preset: p, + endpointChoice: choiceId, + form: { + name: p.id === "custom" ? "" : p.id, + adapter: p.adapter, + baseUrl: p.baseUrlChoices?.length + ? baseUrlForChoice(p.baseUrlChoices, choiceId, p.baseUrl) + : p.baseUrl, + authMode: p.auth, + apiKey: "", + defaultModel: p.defaultModel ?? "", + allowPrivateNetwork: false, + }, }); - setError(""); - setOauthMsg(""); - setOauthMsgTone("ok"); - setManualCode(""); - setManualCodeMsg(""); - setManualCodeOk(true); - }; - - const back = () => { - setPreset(null); - setForm(null); - setEndpointChoice("custom"); - setError(""); - setOauthMsg(""); - setOauthMsgTone("ok"); - setManualCode(""); - setManualCodeMsg(""); - setManualCodeOk(true); }; const submit = async () => { @@ -172,20 +156,20 @@ export default function AddProviderModal({ const resolvedBaseUrl = preset?.baseUrlChoices?.length ? resolvedBaseUrlForChoice(preset.baseUrlChoices, endpointChoice, form.baseUrl) : form.baseUrl.trim(); - if (!reserved && !form.name.trim()) { setError(t("modal.nameRequired")); return; } - if (!reserved && !resolvedBaseUrl) { setError(t("modal.baseUrlRequired")); return; } - if (!reserved && /\{[^}]*\}/.test(resolvedBaseUrl)) { setError(t("modal.baseUrlPlaceholderError")); return; } + if (!reserved && !form.name.trim()) { dispatch({ type: "set-error", error: t("modal.nameRequired") }); return; } + if (!reserved && !resolvedBaseUrl) { dispatch({ type: "set-error", error: t("modal.baseUrlRequired") }); return; } + if (!reserved && /\{[^}]*\}/.test(resolvedBaseUrl)) { dispatch({ type: "set-error", error: t("modal.baseUrlPlaceholderError") }); return; } const submitForm = { ...form, baseUrl: resolvedBaseUrl }; let postBody: { name: string; provider: ProviderPayload }; try { postBody = buildProviderPostBody(preset ?? { id: "custom" }, submitForm); } catch { - setError(t("modal.invalidPreset")); + dispatch({ type: "set-error", error: t("modal.invalidPreset") }); return; } - setSaving(true); - setError(""); + dispatch({ type: "set-saving", saving: true }); + dispatch({ type: "set-error", error: "" }); try { const res = await fetch(`${apiBase}/api/providers`, { method: "POST", @@ -193,111 +177,48 @@ export default function AddProviderModal({ body: JSON.stringify(postBody), }); if (!res.ok) { - const d = await res.json().catch(() => ({})); - setError(d.error || t("modal.failedStatus", { status: res.status })); + const d = await res.json().catch(() => ({})) as { error?: string }; + dispatch({ type: "set-error", error: d.error || t("modal.failedStatus", { status: res.status }) }); return; } onAdded(postBody.name); } catch { - setError(t("modal.networkError")); + dispatch({ type: "set-error", error: t("modal.networkError") }); } finally { - setSaving(false); + dispatch({ type: "set-saving", saving: false }); } }; - // Real OAuth login: open the provider's auth page in a new tab, poll until the proxy stores the token. - const loginOAuth = async (providerId: string) => { - setOauthBusy(true); - setOauthMsg(""); - setOauthMsgTone("ok"); - setManualCode(""); - setManualCodeMsg(""); - setManualCodeOk(true); - try { - const res = await fetch(`${apiBase}/api/oauth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider: providerId }), - }); - const data = await res.json(); - if (!aliveRef.current) return; - if (!res.ok) { - setOauthMsgTone("warn"); - setOauthMsg(data.error === "unknown oauth provider" - ? t("modal.oauthComingSoonShort") - : (data.error || t("modal.loginFailStart"))); - return; - } - // A non-empty url = browser/device flow (the server also opens it). An EMPTY url with a 200 = - // a local-token import (e.g. Anthropic's Claude Code keychain, Grok CLI) that needs no browser - // — just poll status until the credential lands. Don't treat empty url as a failure. - if (data.url) { setOauthMsg(t("modal.waitingLogin")); } - else { setOauthMsg(data.instructions || t("modal.loggingIn")); } - for (let i = 0; i < 100; i++) { - await new Promise(r => setTimeout(r, 2000)); - if (!aliveRef.current) return; // modal closed → stop polling, don't fire onAdded - const s = await fetch(`${apiBase}/api/oauth/status?provider=${providerId}`).then(r => r.json()).catch(() => null); - if (!aliveRef.current) return; - if (s?.loggedIn) { onAdded(providerId); return; } - if (s?.error) { - setOauthMsgTone("warn"); - setOauthMsg(t("modal.loginError", { error: s.error })); - return; - } - } - setOauthMsgTone("warn"); - setOauthMsg(t("modal.loginTimeout")); - } catch { - if (aliveRef.current) { - setOauthMsgTone("warn"); - setOauthMsg(t("modal.networkError")); - } - } finally { - if (aliveRef.current) setOauthBusy(false); - } + const { loginOAuth, submitManualCode: submitManualCodeApi } = useAddProviderOAuth({ apiBase, t, aliveRef, onAdded }); + + const oauthSetters = { + setOauthBusy: (busy: boolean) => dispatch({ type: "set-oauth-busy", busy }), + setOauthMsg: (msg: string) => dispatch({ type: "set-oauth-msg", msg }), + setOauthMsgTone: (tone: "ok" | "warn") => dispatch({ type: "set-oauth-tone", tone }), + setManualCode: (code: string) => dispatch({ type: "set-manual-code", code }), + setManualCodeMsg: (msg: string) => dispatch({ type: "set-manual-code-msg", msg }), + setManualCodeOk: (ok: boolean) => dispatch({ type: "set-manual-code-msg", msg: manualCodeMsg, ok }), }; + const dup = form ? existingNames.includes(form.name.trim()) && form.name.trim() !== "" : false; const requestLoginOAuth = (providerId: string) => { if (oauthBusy) return; if (oauthTosRisk(providerId)) { - setOauthTosPending(providerId); + dispatch({ type: "set-oauth-tos-pending", providerId }); return; } - void loginOAuth(providerId); + void loginOAuth(providerId, oauthSetters); }; - const submitManualCode = async (providerId: string) => { - const input = manualCode.trim(); - if (!input || manualCodeBusy) return; - setManualCodeBusy(true); - setManualCodeMsg(""); - try { - const res = await fetch(`${apiBase}/api/oauth/login/code`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider: providerId, input }), - }); - const data = await res.json().catch(() => ({})); - if (!aliveRef.current) return; - if (!res.ok) { - setManualCodeOk(false); - setManualCodeMsg(t("prov.pasteFail", { error: data.error || res.statusText })); - return; - } - setManualCode(""); - setManualCodeOk(true); - setManualCodeMsg(t("prov.pasteOk")); - } catch { - if (aliveRef.current) { - setManualCodeOk(false); - setManualCodeMsg(t("modal.networkError")); - } - } finally { - if (aliveRef.current) setManualCodeBusy(false); - } + const submitManualCode = (providerId: string) => { + void submitManualCodeApi(providerId, manualCode, manualCodeBusy, { + setManualCodeBusy: busy => dispatch({ type: "set-manual-code-busy", busy }), + setManualCode: code => dispatch({ type: "set-manual-code", code }), + setManualCodeOk: ok => dispatch({ type: "set-manual-code-msg", msg: manualCodeMsg, ok }), + setManualCodeMsg: msg => dispatch({ type: "set-manual-code-msg", msg }), + }); }; - const dup = form ? existingNames.includes(form.name.trim()) && form.name.trim() !== "" : false; const isCustom = preset?.id === "custom"; const isLocal = form?.authMode === "local"; const isReservedForward = preset ? isReservedCodexForwardPreset(preset) : false; @@ -308,7 +229,7 @@ export default function AddProviderModal({
e.stopPropagation()}>

{preset ? t("modal.addNamed", { label: preset.label }) : t("modal.add")}

- +
{!preset ? ( @@ -328,198 +249,42 @@ export default function AddProviderModal({ /> ) : form && ( preset.auth === "oauth" && form.authMode === "oauth" ? ( - // OAuth login pane -
-
{preset.note ?? t("modal.oauthDefaultNote")}
- {oauthSupported.includes(preset.oauthProvider ?? "") ? ( - - ) : ( -
- {t("modal.oauthComingSoon", { label: preset.label })} -
- )} - {oauthMsg && ( -
- {oauthMsg} -
- )} - {oauthBusy && ( -
-
- {t("prov.pasteRedirectHint")} -
-
- setManualCode(e.target.value)} - onKeyDown={e => { - if (e.key === "Enter" && preset.oauthProvider) { - e.preventDefault(); - void submitManualCode(preset.oauthProvider); - } - }} - placeholder={t("prov.pasteRedirect")} - aria-label={t("prov.pasteRedirect")} - disabled={manualCodeBusy} - className="input text-label" - style={{ flex: 1 }} - /> - -
- {manualCodeMsg && ( -
- {manualCodeMsg} -
- )} -
- )} -
- -
- -
-
+ { + dispatch({ type: "use-api-key-instead", form: { ...form, authMode: "key" } }); + }} + onManualCodeChange={code => dispatch({ type: "set-manual-code", code })} + onSubmitManualCode={providerId => { void submitManualCode(providerId); }} + onBack={() => dispatch({ type: "back" })} + /> ) : ( - // API key / Codex-forward / free-tier form -
- {!isReservedForward && !isCustom && !isLocal && !preset.keyOptional && preset.note && ( -
- {t("modal.setupGuide")} -
    -
  1. - {t("modal.setupStep1Prefix")}{" "} - - {t("modal.setupDashboardLink", { label: preset.label })} - {" "} - {t("modal.setupStep1Suffix")} -
  2. -
  3. {t("modal.setupStep2")}
  4. -
  5. {t("modal.setupStep3")}
  6. -
- {preset.note &&
{preset.note}
} - {/\{[^}]*\}/.test(form.baseUrl) && (
{t("modal.baseUrlPlaceholderHint")}
)} -
- )} - - setForm({ ...form, name: e.target.value })} placeholder={t("modal.namePlaceholder")} /> - - {dup &&
{t("modal.duplicateWarn", { name: form.name.trim() })}
} - {!isReservedForward && <> - - - - {preset.baseUrlChoices && preset.baseUrlChoices.length > 0 ? ( - <> - - - - {endpointChoice === "custom" && ( - - setForm({ ...form, baseUrl: e.target.value })} - placeholder={t("modal.baseUrlPlaceholder")} - /> - - )} - - ) : ( - - setForm({ ...form, baseUrl: e.target.value })} placeholder={t("modal.baseUrlPlaceholder")} /> - - )} - {!isReservedForward && ( - - )} - {!isReservedForward && (form?.allowPrivateNetwork ?? false) && ( -

{t("modal.allowPrivateNetworkHint")}

- )} - } - {form.authMode === "forward" ? ( -
- {presetDescription(preset)} -
- ) : form.authMode === "local" ? ( -
- {t("modal.localHint")} -
- ) : preset.keyOptional ? ( -
- {t("modal.freeTierTitle")} — {preset.note ?? t("modal.freeTierDefault")} -
- ) : ( - <> - {preset.dashboardUrl && ( - - {t("modal.getApiKey", { label: preset.label })} - - )} - - setForm({ ...form, apiKey: e.target.value })} placeholder={t("modal.apiKeyPlaceholder")} /> - - - )} - {!isReservedForward && - setForm({ ...form, defaultModel: e.target.value })} placeholder={t("modal.defaultModelPlaceholder")} /> - } - {error &&
{error}
} -
- - {preset.auth === "oauth" && } -
- -
-
+ dispatch({ type: "set-form", form: next })} + onEndpointChoiceChange={choice => dispatch({ type: "set-endpoint-choice", choice })} + onSubmit={() => { void submit(); }} + onUseOauthLogin={() => dispatch({ type: "use-oauth-login", form: { ...form, authMode: "oauth" } })} + onBack={() => dispatch({ type: "back" })} + /> ) )}
@@ -529,24 +294,15 @@ export default function AddProviderModal({ key={oauthTosPending} providerId={oauthTosPending} providerLabel={preset?.label ?? oauthTosPending} - onCancel={() => setOauthTosPending(null)} + onCancel={() => dispatch({ type: "set-oauth-tos-pending", providerId: null })} onContinue={() => { const id = oauthTosPending; if (!id) return; - setOauthTosPending(null); - void loginOAuth(id); + dispatch({ type: "set-oauth-tos-pending", providerId: null }); + void loginOAuth(id, oauthSetters); }} /> )} ); } - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( - - ); -} diff --git a/gui/src/components/add-provider-form-pane.tsx b/gui/src/components/add-provider-form-pane.tsx new file mode 100644 index 000000000..dba144053 --- /dev/null +++ b/gui/src/components/add-provider-form-pane.tsx @@ -0,0 +1,160 @@ +import { IconExternal, IconKey } from "../icons"; +import { useT } from "../i18n"; +import type { CatalogPreset } from "./provider-catalog/provider-presets"; +import type { ProviderPayloadForm } from "../provider-payload"; +import { AddProviderField } from "./add-provider-modal-field"; +import { baseUrlForChoice } from "../base-url-choice"; + +export function AddProviderFormPane({ + preset, + form, + endpointChoice, + error, + saving, + dup, + isCustom, + isLocal, + isReservedForward, + presetDescription, + onFormChange, + onEndpointChoiceChange, + onSubmit, + onUseOauthLogin, + onBack, +}: { + preset: CatalogPreset; + form: ProviderPayloadForm; + endpointChoice: string; + error: string; + saving: boolean; + dup: boolean; + isCustom: boolean; + isLocal: boolean; + isReservedForward: boolean; + presetDescription: (candidate: CatalogPreset) => string | undefined; + onFormChange: (next: ProviderPayloadForm) => void; + onEndpointChoiceChange: (choiceId: string) => void; + onSubmit: () => void; + onUseOauthLogin: () => void; + onBack: () => void; +}) { + const t = useT(); + + return ( +
+ {!isReservedForward && !isCustom && !isLocal && !preset.keyOptional && preset.note && ( +
+ {t("modal.setupGuide")} +
    +
  1. + {t("modal.setupStep1Prefix")}{" "} + + {t("modal.setupDashboardLink", { label: preset.label })} + {" "} + {t("modal.setupStep1Suffix")} +
  2. +
  3. {t("modal.setupStep2")}
  4. +
  5. {t("modal.setupStep3")}
  6. +
+ {preset.note &&
{preset.note}
} + {/\{[^}]*\}/.test(form.baseUrl) && (
{t("modal.baseUrlPlaceholderHint")}
)} +
+ )} + + onFormChange({ ...form, name: e.target.value })} placeholder={t("modal.namePlaceholder")} /> + + {dup &&
{t("modal.duplicateWarn", { name: form.name.trim() })}
} + {!isReservedForward && <> + + + + {preset.baseUrlChoices && preset.baseUrlChoices.length > 0 ? ( + <> + + + + {endpointChoice === "custom" && ( + + onFormChange({ ...form, baseUrl: e.target.value })} + placeholder={t("modal.baseUrlPlaceholder")} + /> + + )} + + ) : ( + + onFormChange({ ...form, baseUrl: e.target.value })} placeholder={t("modal.baseUrlPlaceholder")} /> + + )} + {!isReservedForward && ( + + )} + {!isReservedForward && (form?.allowPrivateNetwork ?? false) && ( +

{t("modal.allowPrivateNetworkHint")}

+ )} + } + {form.authMode === "forward" ? ( +
+ {presetDescription(preset)} +
+ ) : form.authMode === "local" ? ( +
+ {t("modal.localHint")} +
+ ) : preset.keyOptional ? ( +
+ {t("modal.freeTierTitle")} — {preset.note ?? t("modal.freeTierDefault")} +
+ ) : ( + <> + {preset.dashboardUrl && ( + + {t("modal.getApiKey", { label: preset.label })} + + )} + + onFormChange({ ...form, apiKey: e.target.value })} placeholder={t("modal.apiKeyPlaceholder")} /> + + + )} + {!isReservedForward && + onFormChange({ ...form, defaultModel: e.target.value })} placeholder={t("modal.defaultModelPlaceholder")} /> + } + {error &&
{error}
} +
+ + {preset.auth === "oauth" && } +
+ +
+
+ ); +} diff --git a/gui/src/components/add-provider-modal-field.tsx b/gui/src/components/add-provider-modal-field.tsx new file mode 100644 index 000000000..415a0d30a --- /dev/null +++ b/gui/src/components/add-provider-modal-field.tsx @@ -0,0 +1,8 @@ +export function AddProviderField({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ); +} diff --git a/gui/src/components/add-provider-modal-reducer.ts b/gui/src/components/add-provider-modal-reducer.ts new file mode 100644 index 000000000..5db189a12 --- /dev/null +++ b/gui/src/components/add-provider-modal-reducer.ts @@ -0,0 +1,137 @@ +import type { CatalogPreset } from "./provider-catalog/provider-presets"; +import type { ProviderPayloadForm } from "../provider-payload"; + +type Preset = CatalogPreset; +type FormState = ProviderPayloadForm; + +export type AddProviderModalState = { + preset: Preset | null; + form: FormState | null; + saving: boolean; + error: string; + oauthBusy: boolean; + oauthMsg: string; + oauthMsgTone: "ok" | "warn"; + manualCode: string; + manualCodeBusy: boolean; + manualCodeMsg: string; + manualCodeOk: boolean; + endpointChoice: string; + oauthTosPending: string | null; +}; + +export type AddProviderModalAction = + | { type: "choose-preset"; preset: Preset; form: FormState; endpointChoice: string } + | { type: "back" } + | { type: "set-form"; form: FormState } + | { type: "set-endpoint-choice"; choice: string } + | { type: "set-saving"; saving: boolean } + | { type: "set-error"; error: string } + | { type: "set-oauth-busy"; busy: boolean } + | { type: "set-oauth-msg"; msg: string; tone?: "ok" | "warn" } + | { type: "set-oauth-tone"; tone: "ok" | "warn" } + | { type: "set-manual-code"; code: string } + | { type: "set-manual-code-busy"; busy: boolean } + | { type: "set-manual-code-msg"; msg: string; ok?: boolean } + | { type: "set-oauth-tos-pending"; providerId: string | null } + | { type: "use-oauth-login"; form: FormState } + | { type: "use-api-key-instead"; form: FormState }; + +export function createInitialAddProviderState( + initialCustom: boolean, + customLabel: string, +): AddProviderModalState { + const customPreset: Preset = { + id: "custom", + label: customLabel, + adapter: "openai-chat", + baseUrl: "", + auth: "key", + }; + return { + preset: initialCustom ? customPreset : null, + form: initialCustom + ? { name: "", adapter: "openai-chat", baseUrl: "", authMode: "key", apiKey: "", defaultModel: "", allowPrivateNetwork: false } + : null, + saving: false, + error: "", + oauthBusy: false, + oauthMsg: "", + oauthMsgTone: "ok", + manualCode: "", + manualCodeBusy: false, + manualCodeMsg: "", + manualCodeOk: true, + endpointChoice: "custom", + oauthTosPending: null, + }; +} + +export function addProviderModalReducer( + state: AddProviderModalState, + action: AddProviderModalAction, +): AddProviderModalState { + switch (action.type) { + case "choose-preset": + return { + ...state, + preset: action.preset, + form: action.form, + endpointChoice: action.endpointChoice, + error: "", + oauthMsg: "", + oauthMsgTone: "ok", + manualCode: "", + manualCodeMsg: "", + manualCodeOk: true, + }; + case "back": + return { + ...state, + preset: null, + form: null, + endpointChoice: "custom", + error: "", + oauthMsg: "", + oauthMsgTone: "ok", + manualCode: "", + manualCodeMsg: "", + manualCodeOk: true, + }; + case "set-form": + return { ...state, form: action.form }; + case "set-endpoint-choice": + return { ...state, endpointChoice: action.choice }; + case "set-saving": + return { ...state, saving: action.saving }; + case "set-error": + return { ...state, error: action.error }; + case "set-oauth-busy": + return { ...state, oauthBusy: action.busy }; + case "set-oauth-msg": + return { ...state, oauthMsg: action.msg, oauthMsgTone: action.tone ?? state.oauthMsgTone }; + case "set-oauth-tone": + return { ...state, oauthMsgTone: action.tone }; + case "set-manual-code": + return { ...state, manualCode: action.code }; + case "set-manual-code-busy": + return { ...state, manualCodeBusy: action.busy }; + case "set-manual-code-msg": + return { ...state, manualCodeMsg: action.msg, manualCodeOk: action.ok ?? state.manualCodeOk }; + case "set-oauth-tos-pending": + return { ...state, oauthTosPending: action.providerId }; + case "use-oauth-login": + return { ...state, form: action.form, error: "" }; + case "use-api-key-instead": + return { + ...state, + form: action.form, + oauthMsg: "", + oauthMsgTone: "ok", + manualCode: "", + manualCodeMsg: "", + }; + default: + return state; + } +} diff --git a/gui/src/components/add-provider-oauth-pane.tsx b/gui/src/components/add-provider-oauth-pane.tsx new file mode 100644 index 000000000..9afc6f65c --- /dev/null +++ b/gui/src/components/add-provider-oauth-pane.tsx @@ -0,0 +1,105 @@ +import { IconLock } from "../icons"; +import { useT } from "../i18n"; +import type { CatalogPreset } from "./provider-catalog/provider-presets"; + +export function AddProviderOAuthPane({ + preset, + oauthSupported, + oauthBusy, + oauthMsg, + oauthMsgTone, + manualCode, + manualCodeBusy, + manualCodeMsg, + manualCodeOk, + onRequestLogin, + onUseApiKeyInstead, + onManualCodeChange, + onSubmitManualCode, + onBack, +}: { + preset: CatalogPreset; + oauthSupported: string[]; + oauthBusy: boolean; + oauthMsg: string; + oauthMsgTone: "ok" | "warn"; + manualCode: string; + manualCodeBusy: boolean; + manualCodeMsg: string; + manualCodeOk: boolean; + onRequestLogin: (providerId: string) => void; + onUseApiKeyInstead: () => void; + onManualCodeChange: (value: string) => void; + onSubmitManualCode: (providerId: string) => void; + onBack: () => void; +}) { + const t = useT(); + + return ( +
+
{preset.note ?? t("modal.oauthDefaultNote")}
+ {oauthSupported.includes(preset.oauthProvider ?? "") ? ( + + ) : ( +
+ {t("modal.oauthComingSoon", { label: preset.label })} +
+ )} + {oauthMsg && ( +
+ {oauthMsg} +
+ )} + {oauthBusy && ( +
+
+ {t("prov.pasteRedirectHint")} +
+
+ onManualCodeChange(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter" && preset.oauthProvider) { + e.preventDefault(); + onSubmitManualCode(preset.oauthProvider); + } + }} + placeholder={t("prov.pasteRedirect")} + aria-label={t("prov.pasteRedirect")} + disabled={manualCodeBusy} + className="input text-label" + style={{ flex: 1 }} + /> + +
+ {manualCodeMsg && ( +
+ {manualCodeMsg} +
+ )} +
+ )} +
+ +
+ +
+
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index 74dbdf2f6..edfe701eb 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -9,6 +9,7 @@ */ import { useEffect, useMemo, useRef, useState } from "react"; import { baseUrlForChoice, matchChoiceId, resolvedBaseUrlForChoice } from "../../base-url-choice"; +import { readJsonIfOk } from "../../fetch-json"; import { useT } from "../../i18n"; import { IconLock } from "../../icons"; import { isCatalogProviderId } from "../../provider-icons"; @@ -67,9 +68,14 @@ export default function ProviderSettings({ const providerId = item.name; const savedBaseUrl = item.baseUrl; fetch(`${apiBase}/api/provider-presets`) - .then(r => r.json()) - .then((d: { providers?: CatalogPreset[] }) => { + .then(r => readJsonIfOk<{ providers?: CatalogPreset[] }>(r)) + .then((d) => { if (cancelled) return; + if (!d) { + setBaseUrlChoices(undefined); + setChoicesStatus("error"); + return; + } const preset = (d.providers ?? []).find(p => p.id === providerId); const choices = preset?.baseUrlChoices; setBaseUrlChoices(choices); @@ -121,12 +127,16 @@ export default function ProviderSettings({ ? resolvedBaseUrlForChoice(baseUrlChoices, endpointChoice, baseUrl) : baseUrl.trim(); if (!adapter.trim() || !nextBaseUrl) { setMsg({ ok: false, text: t("pws.adapterBaseRequired") }); return false; } - setSaving(true); setMsg(null); - const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork, liveModels }; - const res = await onUpdateProvider(item.name, patch); - setSaving(false); - setMsg(res.ok ? { ok: true, text: t("pws.settingsSaved") } : { ok: false, text: res.error || t("prov.saveFailed") }); - return res.ok; + setSaving(true); + setMsg(null); + try { + const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork, liveModels }; + const res = await onUpdateProvider(item.name, patch); + setMsg(res.ok ? { ok: true, text: t("pws.settingsSaved") } : { ok: false, text: res.error || t("prov.saveFailed") }); + return res.ok; + } finally { + setSaving(false); + } }; const saveRef = useRef(save); diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index de9832b14..a844fe429 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -19,6 +19,7 @@ import { type WorkspaceSections, } from "../../provider-workspace/catalog"; import { providerKind } from "../../provider-workspace/kind"; +import { readJsonIfOk, readJsonOrThrow } from "../../fetch-json"; import { countAvailableModels, parseAvailableModels, parseLiveModelCounts, parseSelectedModels, type ProviderAvailableModels, type ProviderLiveModelCounts, type ProviderModelCounts, type ProviderSelectedModels } from "../../provider-workspace/usage"; import type { ProviderQuotaReportView } from "../../provider-workspace/report"; import { formatProviderDisplayName } from "../../provider-icons"; @@ -118,22 +119,23 @@ export default function ProviderWorkspaceShell({ let cancelled = false; const timeout = window.setTimeout(() => { setModelsLoading(true); - fetch(`${apiBase}/api/selected-models`) - .then(r => r.ok ? r.json() : Promise.reject(new Error(String(r.status)))) - .then(data => { + void (async () => { + try { + const res = await fetch(`${apiBase}/api/selected-models`); + const data = await readJsonOrThrow(res); if (cancelled) return; setModelCounts(countAvailableModels(data)); setAvailableModels(parseAvailableModels(data)); setLiveModelCounts(parseLiveModelCounts(data)); setSelectedModels(parseSelectedModels(data)); setModelsLoadFailed(false); - setModelsLoading(false); - }) - .catch(() => { + } catch { if (cancelled) return; setModelsLoadFailed(true); - setModelsLoading(false); - }); + } finally { + if (!cancelled) setModelsLoading(false); + } + })(); }, 0); return () => { cancelled = true; @@ -144,11 +146,11 @@ export default function ProviderWorkspaceShell({ useEffect(() => { let cancelled = false; fetch(`${apiBase}/api/usage?range=30d`) - .then(r => r.ok ? r.json() : null) - .then((data: { + .then(r => readJsonIfOk<{ providers?: Array<{ provider: string; requests: number; totalTokens?: number }>; models?: Array<{ provider: string; model: string; resolvedModel?: string; requests: number; totalTokens: number; inputTokens: number; outputTokens: number; shareRatio: number; estimatedCostUsd?: number }>; - } | null) => { + }>(r)) + .then((data) => { if (cancelled || !data) return; const byProvider: Record = {}; for (const p of data.providers ?? []) byProvider[p.provider] = { requests: p.requests, totalTokens: p.totalTokens }; @@ -178,8 +180,8 @@ export default function ProviderWorkspaceShell({ useEffect(() => { let cancelled = false; fetch(`${apiBase}/api/provider-quotas`) - .then(r => r.ok ? r.json() : null) - .then((data: { reports?: Array<{ provider: string; label?: string; source?: string; updatedAt?: number; quota?: unknown }> } | null) => { + .then(r => readJsonIfOk<{ reports?: Array<{ provider: string; label?: string; source?: string; updatedAt?: number; quota?: unknown }> }>(r)) + .then((data) => { if (cancelled || !data) return; // Merge so a partial/failed probe cannot wipe a previously good provider row. setQuotaReports(prev => { diff --git a/gui/src/components/use-add-provider-oauth.ts b/gui/src/components/use-add-provider-oauth.ts new file mode 100644 index 000000000..828bc6665 --- /dev/null +++ b/gui/src/components/use-add-provider-oauth.ts @@ -0,0 +1,120 @@ +import { useCallback } from "react"; +import type { TFn } from "../i18n"; +import { readJsonIfOk } from "../fetch-json"; + +export function useAddProviderOAuth({ + apiBase, + t, + aliveRef, + onAdded, +}: { + apiBase: string; + t: TFn; + aliveRef: React.MutableRefObject; + onAdded: (name: string) => void; +}) { + const loginOAuth = useCallback(async ( + providerId: string, + setters: { + setOauthBusy: (v: boolean) => void; + setOauthMsg: (v: string) => void; + setOauthMsgTone: (v: "ok" | "warn") => void; + setManualCode: (v: string) => void; + setManualCodeMsg: (v: string) => void; + setManualCodeOk: (v: boolean) => void; + }, + ) => { + const { setOauthBusy, setOauthMsg, setOauthMsgTone, setManualCode, setManualCodeMsg, setManualCodeOk } = setters; + setOauthBusy(true); + setOauthMsg(""); + setOauthMsgTone("ok"); + setManualCode(""); + setManualCodeMsg(""); + setManualCodeOk(true); + try { + const res = await fetch(`${apiBase}/api/oauth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId }), + }); + if (!aliveRef.current) return; + if (!res.ok) { + const data = await res.json().catch(() => ({})) as { error?: string }; + setOauthMsgTone("warn"); + setOauthMsg(data.error === "unknown oauth provider" + ? t("modal.oauthComingSoonShort") + : (data.error || t("modal.loginFailStart"))); + return; + } + const data = await res.json() as { url?: string; instructions?: string; error?: string }; + if (data.url) { setOauthMsg(t("modal.waitingLogin")); } + else { setOauthMsg(data.instructions || t("modal.loggingIn")); } + for (let i = 0; i < 100; i++) { + await new Promise(r => setTimeout(r, 2000)); + if (!aliveRef.current) return; + const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${providerId}`).catch(() => null); + const s = sRes ? await readJsonIfOk<{ loggedIn?: boolean; error?: string }>(sRes) : null; + if (!aliveRef.current) return; + if (s?.loggedIn) { onAdded(providerId); return; } + if (s?.error) { + setOauthMsgTone("warn"); + setOauthMsg(t("modal.loginError", { error: s.error })); + return; + } + } + setOauthMsgTone("warn"); + setOauthMsg(t("modal.loginTimeout")); + } catch { + if (aliveRef.current) { + setOauthMsgTone("warn"); + setOauthMsg(t("modal.networkError")); + } + } finally { + if (aliveRef.current) setOauthBusy(false); + } + }, [aliveRef, apiBase, onAdded, t]); + + const submitManualCode = useCallback(async ( + providerId: string, + manualCode: string, + manualCodeBusy: boolean, + setters: { + setManualCodeBusy: (v: boolean) => void; + setManualCode: (v: string) => void; + setManualCodeOk: (v: boolean) => void; + setManualCodeMsg: (v: string) => void; + }, + ) => { + const input = manualCode.trim(); + if (!input || manualCodeBusy) return; + const { setManualCodeBusy, setManualCode, setManualCodeOk, setManualCodeMsg } = setters; + setManualCodeBusy(true); + setManualCodeMsg(""); + try { + const res = await fetch(`${apiBase}/api/oauth/login/code`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId, input }), + }); + if (!aliveRef.current) return; + if (!res.ok) { + const data = await res.json().catch(() => ({})) as { error?: string }; + setManualCodeOk(false); + setManualCodeMsg(t("prov.pasteFail", { error: data.error || res.statusText })); + return; + } + setManualCode(""); + setManualCodeOk(true); + setManualCodeMsg(t("prov.pasteOk")); + } catch { + if (aliveRef.current) { + setManualCodeOk(false); + setManualCodeMsg(t("modal.networkError")); + } + } finally { + if (aliveRef.current) setManualCodeBusy(false); + } + }, [aliveRef, apiBase, t]); + + return { loginOAuth, submitManualCode }; +} diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index a0c8553fa..6ea14da7c 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -1,56 +1,34 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import AddProviderModal from "../components/AddProviderModal"; -import AddCodexAccountModal from "../components/AddCodexAccountModal"; -import OAuthTosWarningModal from "../components/OAuthTosWarningModal"; import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell"; import ProviderDetails from "../components/provider-workspace/ProviderDetails"; -import { RemoveConfirmDialog, UnsavedLeaveDialog } from "../components/provider-workspace/ProviderDialogs"; import type { WorkspaceProvider } from "../provider-workspace/catalog"; -import { codexAccountProviderNames, ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; -import type { ProviderUpdatePatch } from "../components/provider-workspace/types"; +import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; import { oauthTosRisk } from "../oauth-tos-risk"; import { Notice } from "../ui"; import { IconPlus } from "../icons"; import { useT } from "../i18n"; -import type { AccountQuota } from "../codex-quota-utils"; import { formatProviderDisplayName } from "../provider-icons"; -import { apiErrorMessage } from "../api-error"; import { useProviderAccountPools } from "../hooks/useProviderAccountPools"; import { useCodexAccountPool } from "../hooks/useCodexAccountPool"; import { useJsonConfigEditor } from "../hooks/useJsonConfigEditor"; - -interface Config { - port: number; - defaultProvider: string; - providers: Record; -} - -interface OAuthStatus { loggedIn: boolean; email?: string; error?: string; done?: boolean; needsReauth?: boolean; activeAccountId?: string | null } -interface ProviderQuotaReport { provider: string; quota: AccountQuota; source: string; updatedAt: number } -interface OAuthAccount { id: string; alias?: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number } - -// Friendly labels for the OAuth providers the proxy supports. -const OAUTH_LABELS: Record = { - xai: "xAI (Grok)", - anthropic: "Anthropic (Claude)", - kimi: "Kimi (Moonshot)", - "google-antigravity": "Google Antigravity", - "github-copilot": "GitHub Copilot", - cursor: "Cursor", -}; -const oauthLabel = (id: string) => OAUTH_LABELS[id] ?? id; +import type { ProvidersConfig } from "./providers-shared"; +import { useProvidersOAuth } from "./use-providers-oauth"; +import { useProvidersCrud } from "./use-providers-crud"; +import { useProvidersFetch } from "./use-providers-fetch"; +import { ProvidersPageModals } from "./providers-page-modals"; +import { buildAccountLoginStatus, buildAddModalAccountRows } from "./providers-page-utils"; export default function Providers({ apiBase }: { apiBase: string }) { const t = useT(); - const [config, setConfig] = useState(null); + const [config, setConfig] = useState(null); const [adding, setAdding] = useState(false); const [status, setStatus] = useState(""); const [statusOk, setStatusOk] = useState(false); const [oauthProviders, setOauthProviders] = useState([]); - const [oauthStatus, setOauthStatus] = useState>({}); + const [oauthStatus, setOauthStatus] = useState>({}); // Value is unread: the workspace shell fetches its own quota view. The setter stays // because the refresh path still primes this cache for that shell. - const [, setQuotaReports] = useState>({}); + const [, setQuotaReports] = useState>({}); const [busy, setBusy] = useState(null); const [loginInfo, setLoginInfo] = useState<{ provider: string; url?: string; instructions?: string; deviceCode?: string } | null>(null); const [workspaceSelected, setWorkspaceSelected] = useState(null); @@ -64,83 +42,17 @@ export default function Providers({ apiBase }: { apiBase: string }) { const removeBusyRef = useRef(false); const oauthLoginGenerationRef = useRef>(new Map()); - const notify = (msg: string, ok: boolean) => { setStatus(msg); setStatusOk(ok); }; + const notify = useCallback((msg: string, ok: boolean = true) => { + setStatus(msg); + setStatusOk(ok); + }, []); useEffect(() => { aliveRef.current = true; return () => { aliveRef.current = false; }; }, []); // Providers hash sync is owned by App (passive replaceHash / deliberate navigateHash). - const fetchConfig = useCallback(async () => { - try { - const res = await fetch(`${apiBase}/api/config`); - const data = await res.json(); - setConfig(data); - } catch { - notify(t("prov.loadConfigFail"), false); - } - }, [apiBase, t]); - - // Load OAuth-capable providers + ChatGPT/Codex pool status (shared by all forward providers). - const fetchOauth = useCallback(async () => { - try { - const provs: string[] = (await fetch(`${apiBase}/api/oauth/providers`).then(r => r.json())).providers ?? []; - setOauthProviders(provs); - const [oauthEntries, codexAccounts, codexActive] = await Promise.all([ - Promise.all(provs.map(async p => { - const s = await fetch(`${apiBase}/api/oauth/status?provider=${p}`).then(r => r.json()).catch(() => ({ loggedIn: false })); - return [p, s] as const; - })), - fetch(`${apiBase}/api/codex-auth/accounts`) - .then(r => r.ok ? r.json() as Promise<{ accounts?: Array<{ id?: string; email?: string; isMain?: boolean; hasCredential?: boolean; needsReauth?: boolean }> }> : null) - .catch(() => null), - fetch(`${apiBase}/api/codex-auth/active`) - .then(r => r.ok ? r.json() as Promise<{ activeCodexAccountId?: string | null }> : null) - .catch(() => null), - ]); - const next: Record = Object.fromEntries(oauthEntries); - const accounts = codexAccounts?.accounts ?? []; - const main = accounts.find(a => a.isMain) ?? accounts[0]; - // The synthetic main row always carries hasCredential: true and a placeholder - // email ("Codex App login") even without a real credential. Only treat it as - // logged in when it has a real email or a pool account has a credential. - const mainIsReal = !!main && !!main.email && main.email !== "Codex App login"; - const poolLoggedIn = accounts.some(a => !a.isMain && (a.hasCredential || a.email)); - const codexLoggedIn = mainIsReal || poolLoggedIn; - const codexEmail = mainIsReal ? main.email : (accounts.find(a => !a.isMain && a.email)?.email ?? undefined); - // Only flag the ACTIVE account for reauth — stale inactive accounts must not - // trigger a Models-tab warning when the active/main account is usable. - const activeId = codexActive?.activeCodexAccountId ?? null; - const activePoolAccount = activeId && activeId !== "__main__" - ? accounts.find(a => a.id === activeId) - : null; - const codexNeedsReauth = activePoolAccount - ? Boolean(activePoolAccount.needsReauth) - : Boolean(main?.needsReauth); - // Built-in openai (and any other forward row) share the same Codex account pool. - next.openai = { - loggedIn: codexLoggedIn, - email: codexEmail, - ...(codexNeedsReauth ? { needsReauth: true } : {}), - }; - setOauthStatus(next); - } catch { /* ignore */ } - }, [apiBase]); - - const fetchProviderQuotas = useCallback(async (refresh = false) => { - try { - const res = await fetch(`${apiBase}/api/provider-quotas${refresh ? "?refresh=1" : ""}`); - if (!res.ok) return; - const data = await res.json() as { reports?: ProviderQuotaReport[] }; - setQuotaReports(prev => { - const next = { ...prev }; - for (const report of data.reports ?? []) { - if (report?.provider) next[report.provider] = report; - } - return next; - }); - } catch { - /* keep last-good */ - } - }, [apiBase]); + const { fetchConfig, fetchOauth, fetchProviderQuotas } = useProvidersFetch({ + apiBase, t, setConfig, setOauthProviders, setOauthStatus, setQuotaReports, notify, + }); // WP3: one Codex account controller for the whole Providers page, shared by the // Overview tab and the Accounts tab so a mutation on either is instantly visible on @@ -153,7 +65,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const pools = useProviderAccountPools({ apiBase, t: t as unknown as Parameters[0]["t"], config, oauthStatus, aliveRef, - notify: (msg, ok) => { setStatus(msg); setStatusOk(!!ok); }, + notify, fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth, }); const { @@ -163,7 +75,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { } = pools; const jsonEditor = useJsonConfigEditor({ apiBase, config, - notify: (msg, ok) => { setStatus(msg); setStatusOk(!!ok); }, + notify, fetchConfig, fetchProviderQuotas, onSaved: () => setModelsRefreshToken(n => n + 1), t: t as unknown as Parameters[0]["t"], }); @@ -184,121 +96,18 @@ export default function Providers({ apiBase }: { apiBase: string }) { }; }, [fetchConfig, fetchOauth, fetchProviderQuotas]); - const cancelLoginOAuth = useCallback(async (provider: string) => { - const gen = (oauthLoginGenerationRef.current.get(provider) ?? 0) + 1; - oauthLoginGenerationRef.current.set(provider, gen); - try { - await fetch(`${apiBase}/api/oauth/login/cancel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider }), - }); - } catch { /* ignore */ } - if (!aliveRef.current) return; - if (oauthLoginGenerationRef.current.get(provider) === gen) { - setBusy(current => current === provider ? null : current); - setLoginInfo(current => current?.provider === provider ? null : current); - } - notify(t("prov.loginCancelled", { provider: oauthLabel(provider) }), false); - }, [apiBase, t]); + const bumpModelsRefresh = () => setModelsRefreshToken(n => n + 1); - const loginOAuth = async (provider: string, addAccount = false, accountId?: string) => { - const nextGen = (oauthLoginGenerationRef.current.get(provider) ?? 0) + 1; - oauthLoginGenerationRef.current.set(provider, nextGen); - const generation = nextGen; - const reauthTargetId = accountId?.trim() || undefined; - setBusy(provider); - setStatus(""); - setLoginInfo(null); - try { - const res = await fetch(`${apiBase}/api/oauth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - provider, - ...(addAccount || reauthTargetId ? { addAccount: true } : {}), - ...(reauthTargetId ? { accountId: reauthTargetId, reauth: true } : {}), - }), - }); - const data = await res.json(); - if (oauthLoginGenerationRef.current.get(provider) !== generation || !aliveRef.current) return; - if (!res.ok) { notify(data.error || t("prov.loginFailStart", { provider: oauthLabel(provider) }), false); return; } - if (data.url || data.instructions || data.deviceCode) { - setLoginInfo({ provider, url: data.url, instructions: data.instructions, deviceCode: data.deviceCode }); - } - const baselineCount = accountSets[provider]?.accounts.length ?? 0; - // Poll until the loopback callback (or device flow / manual paste) completes. - // Prefer s.done so cancel/timeout/error clear "waiting for browser" instead of hanging. - let finished = false; - for (let i = 0; i < 150 && aliveRef.current && oauthLoginGenerationRef.current.get(provider) === generation; i++) { - await new Promise(r => setTimeout(r, 2000)); - if (oauthLoginGenerationRef.current.get(provider) !== generation || !aliveRef.current) return; - const s: (OAuthStatus & { accounts?: OAuthAccount[] }) | null = await fetch(`${apiBase}/api/oauth/status?provider=${provider}`).then(r => r.json()).catch(() => null); - if (!s) continue; - if (s.error) { - setOauthStatus(prev => ({ ...prev, [provider]: s })); - const cancelled = /cancel/i.test(s.error); - notify( - cancelled - ? t("prov.loginCancelled", { provider: oauthLabel(provider) }) - : t("prov.loginError", { provider: oauthLabel(provider), error: s.error }), - false, - ); - setLoginInfo(null); - finished = true; - break; - } - // For add-account / reauth flows the provider may already be "logged in": wait for a - // new slot OR flow completion (same-account re-login won't grow count). - const completed = addAccount || reauthTargetId - ? ((s.accounts?.length ?? 0) > baselineCount || s.done === true) - : (s.loggedIn || s.done === true); - if (completed) { - setOauthStatus(prev => ({ ...prev, [provider]: s })); - const target = reauthTargetId - ? s.accounts?.find(a => a.id === reauthTargetId) - : s.accounts?.find(a => a.active) ?? s.accounts?.find(a => a.id === s.activeAccountId); - if (reauthTargetId && !target) { - notify(t("prov.loginError", { provider: oauthLabel(provider), error: t("prov.reauthAccountMissing") }), false); - setLoginInfo(null); - finished = true; - break; - } - if (target?.needsReauth) { - notify(t("prov.loginError", { provider: oauthLabel(provider), error: t("prov.reauthIdentityMismatch") }), false); - setLoginInfo(null); - finished = true; - break; - } - notify(t("prov.loginOk", { provider: oauthLabel(provider), cmd: "ocx sync" }), true); - setLoginInfo(null); - fetchConfig(); - fetchAccountSets(Object.keys(accountSets).includes(provider) ? Object.keys(accountSets) : [...Object.keys(accountSets), provider]); - fetchProviderQuotas(true); - setModelsRefreshToken(n => n + 1); - finished = true; - break; - } - } - if (!finished && oauthLoginGenerationRef.current.get(provider) === generation && aliveRef.current) { - // Browser abandoned / never completed — stop waiting and cancel the server flow. - await fetch(`${apiBase}/api/oauth/login/cancel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider }), - }).catch(() => {}); - notify(t("prov.loginTimeout", { provider: oauthLabel(provider) }), false); - setLoginInfo(null); - } - } catch { - if (oauthLoginGenerationRef.current.get(provider) === generation) { - notify(t("prov.loginRequestFail", { provider: oauthLabel(provider) }), false); - } - } finally { - if (aliveRef.current && oauthLoginGenerationRef.current.get(provider) === generation) setBusy(null); + const { cancelLoginOAuth, loginOAuth, logoutOAuth } = useProvidersOAuth({ + apiBase, t, aliveRef, oauthLoginGenerationRef, accountSets, + setBusy, setStatus, setLoginInfo, setOauthStatus, notify, + fetchConfig, fetchOauth, fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh, + }); - } - }; + const { removeProvider, confirmRemoveProvider, setProviderDisabled, updateProvider } = useProvidersCrud({ + apiBase, t, removeBusyRef, workspaceSelected, setWorkspaceSelected, setRemoveConfirmName, + notify, fetchConfig, fetchOauth, fetchProviderQuotas, + }); const requestLoginOAuth = (provider: string, addAccount = false) => { if (busy === provider) return; @@ -309,91 +118,6 @@ export default function Providers({ apiBase }: { apiBase: string }) { void loginOAuth(provider, addAccount); }; - - const logoutOAuth = async (provider: string) => { - try { - const res = await fetch(`${apiBase}/api/oauth/logout?provider=${encodeURIComponent(provider)}`, { method: "POST" }); - if (!res.ok) { - notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), false); - return; - } - await Promise.all([ - fetchAccountSets([provider]), - fetchOauth(), - fetchConfig(), - fetchProviderQuotas(true), - ]); - setModelsRefreshToken(n => n + 1); - notify(t("prov.logoutOk", { provider: oauthLabel(provider) }), true); - } catch { - notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), false); - } - }; - - const removeProvider = async (name: string) => { - setRemoveConfirmName(name); - }; - - const confirmRemoveProvider = async () => { - const name = removeConfirmName; - if (!name || removeBusyRef.current) return; - removeBusyRef.current = true; - setRemoveConfirmName(null); - const fallback = t("prov.removeFail", { name }); - try { - const res = await fetch(`${apiBase}/api/providers?name=${encodeURIComponent(name)}`, { method: "DELETE" }); - if (res.ok) { - notify(t("prov.removed", { name }), true); - if (workspaceSelected === name) setWorkspaceSelected(null); - fetchConfig(); - fetchOauth(); - fetchProviderQuotas(true); - } else { - notify(await apiErrorMessage(res, fallback), false); - } - } catch { - notify(fallback, false); - } finally { - removeBusyRef.current = false; - } - }; - - const setProviderDisabled = async (name: string, disabled: boolean) => { - const res = await fetch(`${apiBase}/api/providers?name=${encodeURIComponent(name)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ disabled }), - }); - if (res.ok) { - notify(disabled ? t("prov.disabled", { name }) : t("prov.enabled", { name }), true); - fetchConfig(); - fetchOauth(); - fetchProviderQuotas(true); - return; - } - const data = await res.json().catch(() => ({})); - notify(data.error || (disabled ? t("prov.disableFail", { name }) : t("prov.enableFail", { name })), false); - }; - - const updateProvider = async (name: string, patch: ProviderUpdatePatch): Promise<{ ok: boolean; error?: string }> => { - try { - const res = await fetch(`${apiBase}/api/providers?name=${encodeURIComponent(name)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), - }); - if (res.ok) { - fetchConfig(); - return { ok: true }; - } - const data = await res.json().catch(() => ({})); - return { ok: false, error: data.error || "Update failed" }; - } catch { - return { ok: false, error: "Network error" }; - } - }; - - if (!config) { return ( <> @@ -407,29 +131,10 @@ export default function Providers({ apiBase }: { apiBase: string }) { ); } - const addModalAccountRows = [ - ...codexAccountProviderNames(config.providers) - .map(name => ({ - id: name, - label: formatProviderDisplayName(name), - kind: "codex" as const, - href: "#codex-auth", - })), - ...[...oauthProviders] - .sort((a, b) => a.localeCompare(b)) - .map(id => ({ id, label: oauthLabel(id), kind: "oauth" as const })), - ]; - + const addModalAccountRows = buildAddModalAccountRows(config, oauthProviders); + const accountLoginStatus = buildAccountLoginStatus(config, oauthStatus); const isForwardProvider = (name: string) => config.providers[name]?.authMode === "forward"; - const accountLoginStatus: Record = { ...oauthStatus }; - const codexStatus = oauthStatus.openai; - if (codexStatus) { - for (const [name, prov] of Object.entries(config.providers)) { - if (prov.authMode === "forward") accountLoginStatus[name] = codexStatus; - } - } - const onAccountLogin = async (provider: string) => { if (provider === "openai") { if (busy === "openai") return; @@ -468,23 +173,6 @@ export default function Providers({ apiBase }: { apiBase: string }) { } }; - const bumpModelsRefresh = () => setModelsRefreshToken(n => n + 1); - - const codexLoginModal = codexLoginOpen ? ( - setCodexLoginOpen(false)} - onAdded={() => { - setCodexLoginOpen(false); - notify(t("prov.loginOk", { provider: formatProviderDisplayName("openai"), cmd: "ocx sync" }), true); - void fetchConfig(); - void fetchOauth(); - void fetchProviderQuotas(true); - bumpModelsRefresh(); - }} - /> - ) : null; - return ( <>
@@ -562,58 +250,59 @@ export default function Providers({ apiBase }: { apiBase: string }) { ); }} /> - {adding && ( - { - if (busy) void cancelLoginOAuth(busy); - setAdding(false); - setAddIntent(null); - }} - onAdded={(name) => { setAdding(false); setAddIntent(null); notify(t("prov.added", { name, cmd: "ocx sync" }), true); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); bumpModelsRefresh(); }} - accountRows={addModalAccountRows} - accountStatus={accountLoginStatus} - accountBusy={busy} - onAccountLogin={onAccountLogin} - onAccountCancelLogin={(provider) => { void cancelLoginOAuth(provider); }} - onAccountLogout={(provider) => { void logoutOAuth(provider); }} - onOpen={fetchOauth} - /> - )} - {codexLoginModal} - {removeConfirmName && ( - setRemoveConfirmName(null)} - onConfirm={() => { void confirmRemoveProvider(); }} - /> - )} - {jsonLeaveOpen && ( - { if (!jsonSaving) setJsonLeaveOpen(false); }} - onDiscard={discardJsonEditor} - onSave={() => { void saveConfig(); }} - /> - )} - {oauthTosPending && ( - setOauthTosPending(null)} - onContinue={() => { - const pending = oauthTosPending; - if (!pending) return; - setOauthTosPending(null); - void loginOAuth(pending.provider, pending.addAccount); - }} - /> - )} + { + if (busy) void cancelLoginOAuth(busy); + setAdding(false); + setAddIntent(null); + }} + onAdded={(name) => { + setAdding(false); + setAddIntent(null); + notify(t("prov.added", { name, cmd: "ocx sync" }), true); + fetchConfig(); + fetchOauth(); + fetchProviderQuotas(true); + bumpModelsRefresh(); + }} + onAccountLogin={onAccountLogin} + onAccountCancelLogin={(provider) => { void cancelLoginOAuth(provider); }} + onAccountLogout={(provider) => { void logoutOAuth(provider); }} + onOpenAdd={fetchOauth} + onCloseCodexLogin={() => setCodexLoginOpen(false)} + onCodexAdded={() => { + setCodexLoginOpen(false); + notify(t("prov.loginOk", { provider: formatProviderDisplayName("openai"), cmd: "ocx sync" }), true); + void fetchConfig(); + void fetchOauth(); + void fetchProviderQuotas(true); + bumpModelsRefresh(); + }} + onCancelRemove={() => setRemoveConfirmName(null)} + onConfirmRemove={() => { void confirmRemoveProvider(removeConfirmName); }} + onCancelJsonLeave={() => { if (!jsonSaving) setJsonLeaveOpen(false); }} + onDiscardJson={discardJsonEditor} + onSaveJson={() => { void saveConfig(); }} + onCancelOauthTos={() => setOauthTosPending(null)} + onContinueOauthTos={() => { + const pending = oauthTosPending; + if (!pending) return; + setOauthTosPending(null); + void loginOAuth(pending.provider, pending.addAccount); + }} + /> ); - } diff --git a/gui/src/pages/providers-page-modals.tsx b/gui/src/pages/providers-page-modals.tsx new file mode 100644 index 000000000..69099befa --- /dev/null +++ b/gui/src/pages/providers-page-modals.tsx @@ -0,0 +1,119 @@ +import AddProviderModal from "../components/AddProviderModal"; +import AddCodexAccountModal from "../components/AddCodexAccountModal"; +import OAuthTosWarningModal from "../components/OAuthTosWarningModal"; +import { RemoveConfirmDialog, UnsavedLeaveDialog } from "../components/provider-workspace/ProviderDialogs"; +import type { AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell"; +import type { AccountLoginRow, AccountLoginStatus } from "../components/provider-catalog/ProviderCatalog"; +import type { ProvidersConfig } from "./providers-shared"; +import { oauthLabel } from "./providers-shared"; + +export function ProvidersPageModals({ + apiBase, + config, + adding, + addIntent, + busy, + addModalAccountRows, + accountLoginStatus, + removeConfirmName, + codexLoginOpen, + jsonLeaveOpen, + jsonSaving, + oauthTosPending, + onCloseAdd, + onAdded, + onAccountLogin, + onAccountCancelLogin, + onAccountLogout, + onOpenAdd, + onCloseCodexLogin, + onCodexAdded, + onCancelRemove, + onConfirmRemove, + onCancelJsonLeave, + onDiscardJson, + onSaveJson, + onCancelOauthTos, + onContinueOauthTos, +}: { + apiBase: string; + config: ProvidersConfig; + adding: boolean; + addIntent: AddProviderIntent | null; + busy: string | null; + addModalAccountRows: AccountLoginRow[]; + accountLoginStatus: Record; + removeConfirmName: string | null; + codexLoginOpen: boolean; + jsonLeaveOpen?: boolean; + jsonSaving?: boolean; + oauthTosPending: { provider: string; addAccount: boolean } | null; + onCloseAdd: () => void; + onAdded: (name: string) => void; + onAccountLogin: (provider: string) => void; + onAccountCancelLogin: (provider: string) => void; + onAccountLogout: (provider: string) => void; + onOpenAdd: () => void; + onCloseCodexLogin: () => void; + onCodexAdded: () => void; + onCancelRemove: () => void; + onConfirmRemove: () => void; + onCancelJsonLeave?: () => void; + onDiscardJson?: () => void; + onSaveJson?: () => void; + onCancelOauthTos: () => void; + onContinueOauthTos: () => void; +}) { + return ( + <> + {adding && ( + + )} + {codexLoginOpen && ( + + )} + {removeConfirmName && ( + + )} + {jsonLeaveOpen && onCancelJsonLeave && onDiscardJson && onSaveJson && ( + + )} + {oauthTosPending && ( + + )} + + ); +} diff --git a/gui/src/pages/providers-page-utils.ts b/gui/src/pages/providers-page-utils.ts new file mode 100644 index 000000000..be87dace4 --- /dev/null +++ b/gui/src/pages/providers-page-utils.ts @@ -0,0 +1,37 @@ +import type { AccountLoginRow, AccountLoginStatus } from "../components/provider-catalog/ProviderCatalog"; +import { formatProviderDisplayName } from "../provider-icons"; +import { codexAccountProviderNames } from "../provider-payload"; +import type { OAuthStatus, ProvidersConfig } from "./providers-shared"; +import { oauthLabel } from "./providers-shared"; + +export function buildAddModalAccountRows( + config: ProvidersConfig, + oauthProviders: string[], +): AccountLoginRow[] { + return [ + ...codexAccountProviderNames(config.providers) + .map(name => ({ + id: name, + label: formatProviderDisplayName(name), + kind: "codex" as const, + href: "#codex-auth", + })), + ...[...oauthProviders] + .sort((a, b) => a.localeCompare(b)) + .map(id => ({ id, label: oauthLabel(id), kind: "oauth" as const })), + ]; +} + +export function buildAccountLoginStatus( + config: ProvidersConfig, + oauthStatus: Record, +): Record { + const accountLoginStatus: Record = { ...oauthStatus }; + const codexStatus = oauthStatus.openai; + if (codexStatus) { + for (const [name, prov] of Object.entries(config.providers)) { + if (prov.authMode === "forward") accountLoginStatus[name] = codexStatus; + } + } + return accountLoginStatus; +} diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts new file mode 100644 index 000000000..34ebbc825 --- /dev/null +++ b/gui/src/pages/providers-shared.ts @@ -0,0 +1,54 @@ +export interface ProvidersConfig { + port: number; + defaultProvider: string; + providers: Record; +} + +export interface OAuthStatus { + loggedIn: boolean; + email?: string; + error?: string; + done?: boolean; + needsReauth?: boolean; + activeAccountId?: string | null; +} + +export interface ProviderQuotaReport { + provider: string; + quota: import("../codex-quota-utils").AccountQuota; + source: string; + updatedAt: number; +} + +export interface OAuthAccount { + id: string; + alias?: string; + email?: string; + active: boolean; + needsReauth?: boolean; + expiresAt?: number; +} + +const OAUTH_LABELS: Record = { + xai: "xAI (Grok)", + anthropic: "Anthropic (Claude)", + kimi: "Kimi (Moonshot)", + "google-antigravity": "Google Antigravity", + "github-copilot": "GitHub Copilot", + cursor: "Cursor", +}; + +export const oauthLabel = (id: string) => OAUTH_LABELS[id] ?? id; diff --git a/gui/src/pages/use-providers-crud.ts b/gui/src/pages/use-providers-crud.ts new file mode 100644 index 000000000..3825faf41 --- /dev/null +++ b/gui/src/pages/use-providers-crud.ts @@ -0,0 +1,93 @@ +import { useCallback } from "react"; +import type { TFn } from "../i18n"; +import type { ProviderUpdatePatch } from "../components/provider-workspace/types"; +import { apiErrorMessage } from "../api-error"; + +export function useProvidersCrud({ + apiBase, + t, + removeBusyRef, + workspaceSelected, + setWorkspaceSelected, + setRemoveConfirmName, + notify, + fetchConfig, + fetchOauth, + fetchProviderQuotas, +}: { + apiBase: string; + t: TFn; + removeBusyRef: React.MutableRefObject; + workspaceSelected: string | null; + setWorkspaceSelected: (name: string | null) => void; + setRemoveConfirmName: (name: string | null) => void; + notify: (msg: string, ok: boolean) => void; + fetchConfig: () => Promise; + fetchOauth: () => Promise; + fetchProviderQuotas: (refresh?: boolean) => Promise; +}) { + const removeProvider = useCallback(async (name: string) => { + setRemoveConfirmName(name); + }, [setRemoveConfirmName]); + + const confirmRemoveProvider = useCallback(async (removeConfirmName: string | null) => { + const name = removeConfirmName; + if (!name || removeBusyRef.current) return; + removeBusyRef.current = true; + setRemoveConfirmName(null); + const fallback = t("prov.removeFail", { name }); + try { + const res = await fetch(`${apiBase}/api/providers?name=${encodeURIComponent(name)}`, { method: "DELETE" }); + if (res.ok) { + notify(t("prov.removed", { name }), true); + if (workspaceSelected === name) setWorkspaceSelected(null); + fetchConfig(); + fetchOauth(); + fetchProviderQuotas(true); + } else { + notify(await apiErrorMessage(res, fallback), false); + } + } catch { + notify(fallback, false); + } finally { + removeBusyRef.current = false; + } + }, [apiBase, fetchConfig, fetchOauth, fetchProviderQuotas, notify, removeBusyRef, setRemoveConfirmName, setWorkspaceSelected, t, workspaceSelected]); + + const setProviderDisabled = useCallback(async (name: string, disabled: boolean) => { + const res = await fetch(`${apiBase}/api/providers?name=${encodeURIComponent(name)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ disabled }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})) as { error?: string }; + notify(data.error || (disabled ? t("prov.disableFail", { name }) : t("prov.enableFail", { name })), false); + return; + } + notify(disabled ? t("prov.disabled", { name }) : t("prov.enabled", { name }), true); + fetchConfig(); + fetchOauth(); + fetchProviderQuotas(true); + }, [apiBase, fetchConfig, fetchOauth, fetchProviderQuotas, notify, t]); + + const updateProvider = useCallback(async (name: string, patch: ProviderUpdatePatch): Promise<{ ok: boolean; error?: string }> => { + try { + const res = await fetch(`${apiBase}/api/providers?name=${encodeURIComponent(name)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})) as { error?: string }; + return { ok: false, error: data.error || "Update failed" }; + } + fetchConfig(); + return { ok: true }; + } catch { + return { ok: false, error: "Network error" }; + } + }, [apiBase, fetchConfig]); + + return { removeProvider, confirmRemoveProvider, setProviderDisabled, updateProvider }; +} diff --git a/gui/src/pages/use-providers-fetch.ts b/gui/src/pages/use-providers-fetch.ts new file mode 100644 index 000000000..d022d83ea --- /dev/null +++ b/gui/src/pages/use-providers-fetch.ts @@ -0,0 +1,91 @@ +import { useCallback } from "react"; +import type { TFn } from "../i18n"; +import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; +import type { OAuthStatus, ProviderQuotaReport, ProvidersConfig } from "./providers-shared"; + +export function useProvidersFetch({ + apiBase, + t, + setConfig, + setOauthProviders, + setOauthStatus, + setQuotaReports, + notify, +}: { + apiBase: string; + t: TFn; + setConfig: React.Dispatch>; + setOauthProviders: React.Dispatch>; + setOauthStatus: React.Dispatch>>; + setQuotaReports: React.Dispatch>>; + notify: (msg: string, ok: boolean) => void; +}) { + const fetchConfig = useCallback(async () => { + try { + const res = await fetch(`${apiBase}/api/config`); + const data = await readJsonOrThrow(res); + setConfig(data ?? null); + } catch { + notify(t("prov.loadConfigFail"), false); + } + }, [apiBase, notify, setConfig, t]); + + const fetchOauth = useCallback(async () => { + try { + const provRes = await fetch(`${apiBase}/api/oauth/providers`); + const provData = await readJsonOrThrow<{ providers?: string[] }>(provRes); + const provs: string[] = provData?.providers ?? []; + setOauthProviders(provs); + const [oauthEntries, codexAccounts, codexActive] = await Promise.all([ + Promise.all(provs.map(async p => { + const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${p}`).catch(() => null); + const s = sRes ? (await readJsonIfOk(sRes) ?? { loggedIn: false }) : { loggedIn: false }; + return [p, s] as const; + })), + fetch(`${apiBase}/api/codex-auth/accounts`) + .then(r => readJsonIfOk<{ accounts?: Array<{ id?: string; email?: string; isMain?: boolean; hasCredential?: boolean; needsReauth?: boolean }> }>(r)) + .catch(() => null), + fetch(`${apiBase}/api/codex-auth/active`) + .then(r => readJsonIfOk<{ activeCodexAccountId?: string | null }>(r)) + .catch(() => null), + ]); + const next: Record = Object.fromEntries(oauthEntries); + const accounts = codexAccounts?.accounts ?? []; + const main = accounts.find(a => a.isMain) ?? accounts[0]; + const mainIsReal = !!main && !!main.email && main.email !== "Codex App login"; + const poolLoggedIn = accounts.some(a => !a.isMain && (a.hasCredential || a.email)); + const codexLoggedIn = mainIsReal || poolLoggedIn; + const codexEmail = mainIsReal ? main.email : (accounts.find(a => !a.isMain && a.email)?.email ?? undefined); + const activeId = codexActive?.activeCodexAccountId ?? null; + const activePoolAccount = activeId && activeId !== "__main__" + ? accounts.find(a => a.id === activeId) + : null; + const codexNeedsReauth = activePoolAccount + ? Boolean(activePoolAccount.needsReauth) + : Boolean(main?.needsReauth); + next.openai = { + loggedIn: codexLoggedIn, + email: codexEmail, + ...(codexNeedsReauth ? { needsReauth: true } : {}), + }; + setOauthStatus(next); + } catch { /* ignore */ } + }, [apiBase, setOauthProviders, setOauthStatus]); + + const fetchProviderQuotas = useCallback(async (refresh = false) => { + try { + const res = await fetch(`${apiBase}/api/provider-quotas${refresh ? "?refresh=1" : ""}`); + const data = await readJsonIfOk<{ reports?: ProviderQuotaReport[] }>(res); + if (!data) return; + setQuotaReports(prev => { + const next = { ...prev }; + for (const report of data.reports ?? []) { + if (report?.provider) next[report.provider] = report; + } + return next; + }); + } catch { /* keep last-good */ } + }, [apiBase, setQuotaReports]); + + return { fetchConfig, fetchOauth, fetchProviderQuotas }; +} diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts new file mode 100644 index 000000000..de4d4edb5 --- /dev/null +++ b/gui/src/pages/use-providers-oauth.ts @@ -0,0 +1,180 @@ +import { useCallback } from "react"; +import type { TFn } from "../i18n"; +import { readJsonIfOk } from "../fetch-json"; +import type { OAuthAccount, OAuthStatus } from "./providers-shared"; +import { oauthLabel } from "./providers-shared"; + +export function useProvidersOAuth({ + apiBase, + t, + aliveRef, + oauthLoginGenerationRef, + accountSets, + setBusy, + setStatus, + setLoginInfo, + setOauthStatus, + notify, + fetchConfig, + fetchOauth, + fetchAccountSets, + fetchProviderQuotas, + bumpModelsRefresh, +}: { + apiBase: string; + t: TFn; + aliveRef: React.MutableRefObject; + oauthLoginGenerationRef: React.MutableRefObject>; + accountSets: Record; + setBusy: React.Dispatch>; + setStatus: React.Dispatch>; + setLoginInfo: React.Dispatch>; + setOauthStatus: React.Dispatch>>; + notify: (msg: string, ok: boolean) => void; + fetchConfig: () => Promise; + fetchOauth: () => Promise; + fetchAccountSets: (providers: string[]) => Promise; + fetchProviderQuotas: (refresh?: boolean) => Promise; + bumpModelsRefresh: () => void; +}) { + const cancelLoginOAuth = useCallback(async (provider: string) => { + const gen = (oauthLoginGenerationRef.current.get(provider) ?? 0) + 1; + oauthLoginGenerationRef.current.set(provider, gen); + try { + await fetch(`${apiBase}/api/oauth/login/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + }); + } catch { /* ignore */ } + if (!aliveRef.current) return; + if (oauthLoginGenerationRef.current.get(provider) === gen) { + setBusy(current => current === provider ? null : current); + setLoginInfo(current => current?.provider === provider ? null : current); + } + notify(t("prov.loginCancelled", { provider: oauthLabel(provider) }), false); + }, [aliveRef, apiBase, notify, oauthLoginGenerationRef, setBusy, setLoginInfo, t]); + + const loginOAuth = async (provider: string, addAccount = false, accountId?: string) => { + const nextGen = (oauthLoginGenerationRef.current.get(provider) ?? 0) + 1; + oauthLoginGenerationRef.current.set(provider, nextGen); + const generation = nextGen; + const reauthTargetId = accountId?.trim() || undefined; + setBusy(provider); + setStatus(""); + setLoginInfo(null); + try { + const res = await fetch(`${apiBase}/api/oauth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + ...(addAccount || reauthTargetId ? { addAccount: true } : {}), + ...(reauthTargetId ? { accountId: reauthTargetId, reauth: true } : {}), + }), + }); + if (oauthLoginGenerationRef.current.get(provider) !== generation || !aliveRef.current) return; + if (!res.ok) { + const data = await res.json().catch(() => ({})) as { error?: string }; + notify(data.error || t("prov.loginFailStart", { provider: oauthLabel(provider) }), false); + return; + } + const data = await res.json() as { url?: string; instructions?: string; deviceCode?: string }; + if (data.url || data.instructions || data.deviceCode) { + setLoginInfo({ provider, url: data.url, instructions: data.instructions, deviceCode: data.deviceCode }); + } + const baselineCount = accountSets[provider]?.accounts.length ?? 0; + let finished = false; + for (let i = 0; i < 150 && aliveRef.current && oauthLoginGenerationRef.current.get(provider) === generation; i++) { + await new Promise(r => setTimeout(r, 2000)); + if (oauthLoginGenerationRef.current.get(provider) !== generation || !aliveRef.current) return; + const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${provider}`).catch(() => null); + const s: (OAuthStatus & { accounts?: OAuthAccount[] }) | null = sRes + ? ((await readJsonIfOk(sRes)) ?? null) + : null; + if (!s) continue; + if (s.error) { + setOauthStatus(prev => ({ ...prev, [provider]: s })); + const cancelled = /cancel/i.test(s.error); + notify( + cancelled + ? t("prov.loginCancelled", { provider: oauthLabel(provider) }) + : t("prov.loginError", { provider: oauthLabel(provider), error: s.error }), + false, + ); + setLoginInfo(null); + finished = true; + break; + } + const completed = addAccount || reauthTargetId + ? ((s.accounts?.length ?? 0) > baselineCount || s.done === true) + : (s.loggedIn || s.done === true); + if (completed) { + setOauthStatus(prev => ({ ...prev, [provider]: s })); + const target = reauthTargetId + ? s.accounts?.find(a => a.id === reauthTargetId) + : s.accounts?.find(a => a.active) ?? s.accounts?.find(a => a.id === s.activeAccountId); + if (reauthTargetId && !target) { + notify(t("prov.loginError", { provider: oauthLabel(provider), error: t("prov.reauthAccountMissing") }), false); + setLoginInfo(null); + finished = true; + break; + } + if (target?.needsReauth) { + notify(t("prov.loginError", { provider: oauthLabel(provider), error: t("prov.reauthIdentityMismatch") }), false); + setLoginInfo(null); + finished = true; + break; + } + notify(t("prov.loginOk", { provider: oauthLabel(provider), cmd: "ocx sync" }), true); + setLoginInfo(null); + fetchConfig(); + const knownProviders = Object.keys(accountSets); + const knownSet = new Set(knownProviders); + fetchAccountSets(knownSet.has(provider) ? knownProviders : [...knownProviders, provider]); + fetchProviderQuotas(true); + bumpModelsRefresh(); + finished = true; + break; + } + } + if (!finished && oauthLoginGenerationRef.current.get(provider) === generation && aliveRef.current) { + await fetch(`${apiBase}/api/oauth/login/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + }).catch(() => {}); + notify(t("prov.loginTimeout", { provider: oauthLabel(provider) }), false); + setLoginInfo(null); + } + } catch { + if (oauthLoginGenerationRef.current.get(provider) === generation) { + notify(t("prov.loginRequestFail", { provider: oauthLabel(provider) }), false); + } + } finally { + if (aliveRef.current && oauthLoginGenerationRef.current.get(provider) === generation) setBusy(null); + } + }; + + const logoutOAuth = async (provider: string) => { + try { + const res = await fetch(`${apiBase}/api/oauth/logout?provider=${encodeURIComponent(provider)}`, { method: "POST" }); + if (!res.ok) { + notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), false); + return; + } + await Promise.all([ + fetchAccountSets([provider]), + fetchOauth(), + fetchConfig(), + fetchProviderQuotas(true), + ]); + bumpModelsRefresh(); + notify(t("prov.logoutOk", { provider: oauthLabel(provider) }), true); + } catch { + notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), false); + } + }; + + return { cancelLoginOAuth, loginOAuth, logoutOAuth }; +} diff --git a/gui/tests/rail-hover-delete.test.ts b/gui/tests/rail-hover-delete.test.ts index e780f6cca..dde87089e 100644 --- a/gui/tests/rail-hover-delete.test.ts +++ b/gui/tests/rail-hover-delete.test.ts @@ -43,19 +43,22 @@ test("the accelerator stays out of the tab order and the accessibility tree", as test("deleting routes through the existing confirmation dialog", async () => { const shell = await read("../src/components/provider-workspace/ProviderWorkspaceShell.tsx"); const providers = await read("../src/pages/Providers.tsx"); + const crud = await read("../src/pages/use-providers-crud.ts"); + const modals = await read("../src/pages/providers-page-modals.tsx"); // Providers hands the rail the same callback the detail header uses... expect(providers).toContain("onRemoveProvider={removeProvider}"); expect(shell).toContain("onRemoveProvider(item.name)"); // ...and that callback only opens the dialog; it never deletes directly. - const handler = providers.slice( - providers.indexOf("const removeProvider = async"), - providers.indexOf("const confirmRemoveProvider"), + // CRUD lives in the extracted hook after the Providers split. + const handler = crud.slice( + crud.indexOf("const removeProvider = useCallback"), + crud.indexOf("const confirmRemoveProvider"), ); expect(handler).toContain("setRemoveConfirmName(name)"); expect(handler).not.toContain("method: \"DELETE\""); - expect(providers).toContain("