fix(codex): handle native login availability checks

This commit is contained in:
saladday committed 2026-08-17 01:30:04 -04:00
1 parent 3d126f458a
commit a6594f8ae6
9 files changed
+308 -12

No files matched your search

@@ -95,6 +95,9 @@ interface CodexFormFieldsProps {
codexOauthNoneOptionDescription?: string;
codexOauthAllowUnboundSelection?: boolean;
codexOauthAllowUnboundSelectionWithoutStatus?: boolean;
codexOauthUnboundSelectionLoading?: boolean;
codexOauthUnboundSelectionError?: boolean;
onCodexOauthUnboundSelectionRetry?: () => void;
codexOauthNativeLoginOnly?: boolean;
codexOauthRequireExplicitSelection?: boolean;
@@ -389,6 +392,9 @@ export function CodexFormFields({
codexOauthNoneOptionDescription,
codexOauthAllowUnboundSelection,
codexOauthAllowUnboundSelectionWithoutStatus,
codexOauthUnboundSelectionLoading,
codexOauthUnboundSelectionError,
onCodexOauthUnboundSelectionRetry,
codexOauthNativeLoginOnly,
codexOauthRequireExplicitSelection,
shouldShowSpeedTest,
@@ -739,6 +745,9 @@ export function CodexFormFields({
allowUnboundSelectionWithoutStatus={
codexOauthAllowUnboundSelectionWithoutStatus
}
unboundSelectionLoading={codexOauthUnboundSelectionLoading}
unboundSelectionError={codexOauthUnboundSelectionError}
onUnboundSelectionRetry={onCodexOauthUnboundSelectionRetry}
nativeLoginOnly={codexOauthNativeLoginOnly}
requireExplicitSelection={codexOauthRequireExplicitSelection}
/>
@@ -56,6 +56,12 @@ interface CodexOAuthSectionProps {
allowUnboundSelection?: boolean;
/** 不绑定选项不依赖托管账号状态,可在状态加载失败时继续选择 */
allowUnboundSelectionWithoutStatus?: boolean;
/** 是否仍在确认不绑定选项可用;加载期间保留选项但禁止选择 */
unboundSelectionLoading?: boolean;
/** 无法确认不绑定选项是否可用 */
unboundSelectionError?: boolean;
/** 重新确认不绑定选项是否可用 */
onUnboundSelectionRetry?: () => void;
/** 固定展示原生 Codex 当前登录,不允许改绑 */
nativeLoginOnly?: boolean;
/** 新建官方卡时不预选登录方式,要求用户明确选择 */
@@ -86,6 +92,9 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
noneOptionDescription,
allowUnboundSelection = true,
allowUnboundSelectionWithoutStatus = false,
unboundSelectionLoading = false,
unboundSelectionError = false,
onUnboundSelectionRetry,
nativeLoginOnly = false,
requireExplicitSelection = false,
fastModeEnabled = false,
@@ -203,6 +212,8 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
(allowUnboundSelection
? (noneOptionLabel ?? t("codexOauth.useDefaultAccount", "使用默认账号"))
: t("codexOauth.selectAccountPlaceholder", "选择一个 ChatGPT 账号"));
const unboundSelectionUnavailable =
unboundSelectionLoading || unboundSelectionError;
const accountSelect = (isStatusSuccess ||
(allowUnboundSelection && allowUnboundSelectionWithoutStatus)) &&
@@ -304,10 +315,18 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
{allowUnboundSelection && (
<SelectItem
value="none"
disabled={unboundSelectionUnavailable}
className="min-w-0 overflow-hidden py-2 pl-6 [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1 [&>span:last-child]:overflow-hidden"
>
<div className="flex min-w-0 items-center gap-2">
<span aria-hidden className="h-4 w-4 shrink-0" />
{unboundSelectionLoading ? (
<Loader2
aria-hidden
className="h-4 w-4 shrink-0 animate-spin text-muted-foreground"
/>
) : (
<span aria-hidden className="h-4 w-4 shrink-0" />
)}
<span className="shrink-0 text-sm font-medium leading-5">
{noneOptionLabel ??
t("codexOauth.useDefaultAccount", "使用默认账号")}
@@ -317,6 +336,14 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
{noneOptionDescription}
</span>
)}
{unboundSelectionLoading && (
<span className="sr-only">
{t(
"codexOauth.nativeLoginAvailabilityLoading",
"正在检查 Codex 登录是否可用...",
)}
</span>
)}
</div>
</SelectItem>
)}
@@ -384,6 +411,34 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
</div>
)}
{unboundSelectionError && (
<div
role="alert"
className="flex items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">
{t(
"codexOauth.nativeLoginAvailabilityLoadFailed",
"无法检查 Codex 登录是否可用,请重试。",
)}
</span>
{onUnboundSelectionRetry && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0"
disabled={unboundSelectionLoading}
onClick={onUnboundSelectionRetry}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("codexOauth.retry", "重试")}
</Button>
)}
</div>
)}
{!isStatusSuccess && !isStatusError && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -308,22 +308,40 @@ function ProviderFormFull({
const isEditMode = Boolean(initialData);
const isCodexNativeLoginProvider =
appId === "codex" && providerId === CODEX_OFFICIAL_PROVIDER_ID;
const shouldCheckCodexNativeLoginProvider =
appId === "codex" && !isCodexNativeLoginProvider;
const startsAsLegacyUnboundCodexOfficial =
appId === "codex" &&
isEditMode &&
providerId !== CODEX_OFFICIAL_PROVIDER_ID &&
initialData?.category === "official" &&
!resolveManagedAccountId(initialData.meta, "codex_oauth")?.trim();
const { data: codexNativeLoginProviderExists = true } = useQuery({
const {
data: codexNativeLoginProviderExists,
isError: didCodexNativeLoginProviderFail,
isFetching: isCodexNativeLoginProviderFetching,
refetch: refetchCodexNativeLoginProvider,
} = useQuery({
queryKey: ["providers", "codex", "native-login-exists"],
enabled: appId === "codex" && !isCodexNativeLoginProvider,
enabled: shouldCheckCodexNativeLoginProvider,
queryFn: async () => {
const providers = await providersApi.getAll("codex");
return Boolean(providers[CODEX_OFFICIAL_PROVIDER_ID]);
},
});
const isCodexNativeLoginProviderLoading =
shouldCheckCodexNativeLoginProvider && isCodexNativeLoginProviderFetching;
const isCodexNativeLoginProviderError =
shouldCheckCodexNativeLoginProvider &&
didCodexNativeLoginProviderFail &&
!isCodexNativeLoginProviderLoading;
const isCodexNativeLoginProviderUnavailable =
isCodexNativeLoginProviderLoading || isCodexNativeLoginProviderError;
const isCodexNativeLoginProviderMissing =
codexNativeLoginProviderExists === false &&
!isCodexNativeLoginProviderUnavailable;
const canCreateCodexNativeLoginProvider =
appId === "codex" && !isEditMode && !codexNativeLoginProviderExists;
appId === "codex" && !isEditMode && isCodexNativeLoginProviderMissing;
const queryClient = useQueryClient();
const { data: settingsData } = useSettingsQuery();
const showCommonConfigNotice =
@@ -823,9 +841,23 @@ function ProviderFormFull({
const canSelectCodexNativeLogin =
isCodexNativeLoginProvider ||
canCreateCodexNativeLoginProvider ||
(isEditMode && isCodexOfficialProvider && !codexNativeLoginProviderExists);
(isEditMode &&
isCodexOfficialProvider &&
isCodexNativeLoginProviderMissing);
const showCodexNativeLoginOption =
canSelectCodexNativeLogin || isCodexNativeLoginProviderUnavailable;
const requiresExplicitCodexOfficialSelection =
isCodexOfficialProvider && !hasExplicitCodexOfficialSelection;
isCodexOfficialProvider &&
(!hasExplicitCodexOfficialSelection ||
(!selectedCodexAccountId &&
!canSelectCodexNativeLogin &&
!isCodexNativeLoginProviderUnavailable));
const hasUnconfirmedCodexNativeLoginSelection =
isCodexOfficialProvider &&
hasExplicitCodexOfficialSelection &&
!selectedCodexAccountId &&
!canSelectCodexNativeLogin &&
isCodexNativeLoginProviderUnavailable;
const requiresCodexOauthLogin =
isClaudeCodexOauthProvider || isCodexOfficialManagedOauthBound;
@@ -1296,6 +1328,18 @@ function ProviderFormFull({
);
return;
}
if (hasUnconfirmedCodexNativeLoginSelection) {
toast.error(
isCodexNativeLoginProviderError
? t("codexOauth.nativeLoginAvailabilityLoadFailed", {
defaultValue: "无法检查 Codex 登录是否可用,请重试。",
})
: t("codexOauth.nativeLoginAvailabilityLoading", {
defaultValue: "正在检查 Codex 登录是否可用...",
}),
);
return;
}
if (requiresCodexOauthLogin && isCodexOauthStatusError) {
toast.error(
t("codexOauth.statusLoadFailed", {
@@ -2482,10 +2526,17 @@ function ProviderFormFull({
codexOauthNoneOptionDescription={t(
"codex.followCodexLoginDescription",
)}
codexOauthAllowUnboundSelection={canSelectCodexNativeLogin}
codexOauthAllowUnboundSelection={showCodexNativeLoginOption}
codexOauthAllowUnboundSelectionWithoutStatus={
canSelectCodexNativeLogin
}
codexOauthUnboundSelectionLoading={
isCodexNativeLoginProviderLoading
}
codexOauthUnboundSelectionError={isCodexNativeLoginProviderError}
onCodexOauthUnboundSelectionRetry={() =>
void refetchCodexNativeLoginProvider()
}
codexOauthRequireExplicitSelection={
requiresExplicitCodexOfficialSelection
}
+2
View File
@@ -1410,6 +1410,8 @@
"officialAccountPlaceholder": "Choose a sign-in method",
"addOrManageAccounts": "Add or manage ChatGPT accounts…",
"explicitSelectionRequired": "Choose a sign-in method",
"nativeLoginAvailabilityLoading": "Checking Codex login availability...",
"nativeLoginAvailabilityLoadFailed": "Could not check Codex login availability. Please retry.",
"useDefaultAccount": "Use default account",
"noneOptionLabel": "Follow Codex login",
"reauthTitle": "Some accounts need to sign in again",
+2
View File
@@ -1410,6 +1410,8 @@
"officialAccountPlaceholder": "ログイン方法を選択",
"addOrManageAccounts": "ChatGPT アカウントを追加または管理…",
"explicitSelectionRequired": "ログイン方法を選択してください",
"nativeLoginAvailabilityLoading": "Codex ログインが利用可能か確認しています...",
"nativeLoginAvailabilityLoadFailed": "Codex ログインが利用可能か確認できませんでした。再試行してください。",
"useDefaultAccount": "デフォルトアカウントを使用",
"noneOptionLabel": "Codex のログインに追従",
"reauthTitle": "再ログインが必要なアカウントがあります",
+2
View File
@@ -1411,6 +1411,8 @@
"officialAccountPlaceholder": "請選擇登入方式",
"addOrManageAccounts": "新增或管理 ChatGPT 帳號…",
"explicitSelectionRequired": "請先選擇登入方式",
"nativeLoginAvailabilityLoading": "正在檢查 Codex 登入是否可用...",
"nativeLoginAvailabilityLoadFailed": "無法檢查 Codex 登入是否可用,請重試。",
"useDefaultAccount": "使用預設帳號",
"noneOptionLabel": "跟隨 Codex 登入",
"reauthTitle": "部分帳號需要重新登入",
+2
View File
@@ -1410,6 +1410,8 @@
"officialAccountPlaceholder": "请选择登录方式",
"addOrManageAccounts": "添加或管理 ChatGPT 账号…",
"explicitSelectionRequired": "请先选择登录方式",
"nativeLoginAvailabilityLoading": "正在检查 Codex 登录是否可用...",
"nativeLoginAvailabilityLoadFailed": "无法检查 Codex 登录是否可用,请重试。",
"useDefaultAccount": "使用默认账号",
"noneOptionLabel": "跟随 Codex 登录",
"reauthTitle": "部分账号需要重新登录",
@@ -254,6 +254,55 @@ describe("CodexOAuthSection", () => {
expect(onAccountSelect).toHaveBeenCalledWith(null);
});
it("shows the current-login option as disabled while availability loads", async () => {
const user = userEvent.setup();
render(
<CodexOAuthSection
mode="select"
selectedAccountId={null}
onAccountSelect={vi.fn()}
noneOptionLabel="Follow Codex login"
allowUnboundSelection
unboundSelectionLoading
requireExplicitSelection
/>,
);
await user.click(screen.getByRole("combobox"));
const option = await screen.findByRole("option", {
name: /Follow Codex login.*正在检查 Codex 登录是否可用/,
});
expect(option).toHaveAttribute("aria-disabled", "true");
expect(option.querySelector(".animate-spin")).toBeInTheDocument();
});
it("explains an availability error and retries it", async () => {
const user = userEvent.setup();
const onRetry = vi.fn();
render(
<CodexOAuthSection
mode="select"
selectedAccountId={null}
onAccountSelect={vi.fn()}
noneOptionLabel="Follow Codex login"
allowUnboundSelection
unboundSelectionError
onUnboundSelectionRetry={onRetry}
/>,
);
expect(screen.getByRole("alert")).toHaveTextContent(
"无法检查 Codex 登录是否可用,请重试。",
);
await user.click(screen.getByRole("button", { name: "重试" }));
expect(onRetry).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("combobox"));
expect(
await screen.findByRole("option", { name: "Follow Codex login" }),
).toHaveAttribute("aria-disabled", "true");
});
it("keeps an offline unbound choice available when account status fails", async () => {
const user = userEvent.setup();
const authResult = mocks.useCodexOauth();
@@ -1,4 +1,10 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -41,6 +47,9 @@ vi.mock("@/components/providers/forms/CodexOAuthSection", () => ({
onSelectionConfirmed,
onSelectionInvalidated,
allowUnboundSelection = true,
unboundSelectionLoading = false,
unboundSelectionError = false,
onUnboundSelectionRetry,
nativeLoginOnly = false,
requireExplicitSelection = false,
}: {
@@ -48,6 +57,9 @@ vi.mock("@/components/providers/forms/CodexOAuthSection", () => ({
onSelectionConfirmed?: () => void;
onSelectionInvalidated?: () => void;
allowUnboundSelection?: boolean;
unboundSelectionLoading?: boolean;
unboundSelectionError?: boolean;
onUnboundSelectionRetry?: () => void;
nativeLoginOnly?: boolean;
requireExplicitSelection?: boolean;
}) => (
@@ -58,6 +70,12 @@ vi.mock("@/components/providers/forms/CodexOAuthSection", () => ({
<output data-testid="explicit-selection-required">
{requireExplicitSelection ? "true" : "false"}
</output>
<output data-testid="native-login-loading">
{unboundSelectionLoading ? "true" : "false"}
</output>
<output data-testid="native-login-error">
{unboundSelectionError ? "true" : "false"}
</output>
<button
type="button"
disabled={nativeLoginOnly}
@@ -71,6 +89,7 @@ vi.mock("@/components/providers/forms/CodexOAuthSection", () => ({
{allowUnboundSelection && (
<button
type="button"
disabled={unboundSelectionLoading || unboundSelectionError}
onClick={() => {
onSelectionConfirmed?.();
onAccountSelect?.(null);
@@ -79,6 +98,14 @@ vi.mock("@/components/providers/forms/CodexOAuthSection", () => ({
select-native-login
</button>
)}
{unboundSelectionError && (
<div role="alert">
native-login-availability-error
<button type="button" onClick={onUnboundSelectionRetry}>
retry-native-login-lookup
</button>
</div>
)}
<button
type="button"
onClick={() => {
@@ -173,8 +200,10 @@ vi.mock("@/lib/query", async (importOriginal) => {
};
});
function renderCodexForm(onSubmit: (values: ProviderFormValues) => void) {
const queryClient = createTestQueryClient();
function renderCodexForm(
onSubmit: (values: ProviderFormValues) => void,
queryClient = createTestQueryClient(),
) {
return render(
<QueryClientProvider client={queryClient}>
<ProviderForm
@@ -207,6 +236,14 @@ function renderClaudeCodexForm(onSubmit: (values: ProviderFormValues) => void) {
);
}
async function waitForNativeLoginLookup() {
await waitFor(() =>
expect(screen.getByTestId("native-login-loading")).toHaveTextContent(
"false",
),
);
}
describe("ProviderForm Codex Official managed account", () => {
beforeEach(() => {
authState.codexReauthRequired = false;
@@ -260,6 +297,7 @@ describe("ProviderForm Codex Official managed account", () => {
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
await screen.findByRole("button", { name: "select-managed-account" });
await waitForNativeLoginLookup();
expect(
screen.queryByRole("button", { name: "select-native-login" }),
).not.toBeInTheDocument();
@@ -277,6 +315,7 @@ describe("ProviderForm Codex Official managed account", () => {
renderCodexForm(onSubmit);
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
await waitForNativeLoginLookup();
const nativeLogin = await screen.findByRole("button", {
name: "select-native-login",
});
@@ -289,6 +328,80 @@ describe("ProviderForm Codex Official managed account", () => {
expect(submitted.meta?.authBinding).toBeUndefined();
});
it("rechecks a native choice safely and offers retry on failure", async () => {
let lookupState: "ready" | "pending" | "occupied" = "ready";
let rejectPendingLookup!: (error: Error) => void;
providerApiMocks.getAll.mockImplementation((appId: string) => {
if (appId !== "codex" || lookupState === "ready") {
return Promise.resolve({});
}
if (lookupState === "occupied") {
return Promise.resolve({ "codex-official": {} });
}
return new Promise((_, reject) => {
rejectPendingLookup = reject;
});
});
const queryClient = createTestQueryClient();
const onSubmit = vi.fn();
renderCodexForm(onSubmit, queryClient);
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
await waitForNativeLoginLookup();
fireEvent.click(
screen.getByRole("button", { name: "select-native-login" }),
);
lookupState = "pending";
act(() => {
void queryClient.invalidateQueries({
queryKey: ["providers", "codex", "native-login-exists"],
});
});
await waitFor(() =>
expect(screen.getByTestId("native-login-loading")).toHaveTextContent(
"true",
),
);
expect(
screen.getByRole("button", { name: "select-native-login" }),
).toBeDisabled();
act(() => rejectPendingLookup(new Error("refetch failed")));
await waitFor(() =>
expect(screen.getByTestId("native-login-error")).toHaveTextContent(
"true",
),
);
expect(screen.getByTestId("explicit-selection-required")).toHaveTextContent(
"false",
);
expect(
screen.getByRole("button", { name: "select-native-login" }),
).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() =>
expect(toastMocks.error).toHaveBeenCalledWith(
"无法检查 Codex 登录是否可用,请重试。",
),
);
expect(onSubmit).not.toHaveBeenCalled();
lookupState = "occupied";
fireEvent.click(
screen.getByRole("button", { name: "retry-native-login-lookup" }),
);
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "select-native-login" }),
).not.toBeInTheDocument(),
);
expect(screen.getByTestId("explicit-selection-required")).toHaveTextContent(
"true",
);
});
it("does not silently create the current-login card before a choice", async () => {
providerApiMocks.getAll.mockResolvedValueOnce({});
const onSubmit = vi.fn();
@@ -333,8 +446,14 @@ describe("ProviderForm Codex Official managed account", () => {
expect(onSubmit).not.toHaveBeenCalled();
});
it("allows the fixed Official card to switch to a managed account", async () => {
it("ignores cached lookup failures for the fixed Official card", async () => {
const queryClient = createTestQueryClient();
await expect(
queryClient.fetchQuery({
queryKey: ["providers", "codex", "native-login-exists"],
queryFn: () => Promise.reject(new Error("cached lookup failure")),
}),
).rejects.toThrow("cached lookup failure");
const onSubmit = vi.fn();
render(
<QueryClientProvider client={queryClient}>
@@ -354,6 +473,7 @@ describe("ProviderForm Codex Official managed account", () => {
);
expect(screen.getByTestId("native-login-only")).toHaveTextContent("false");
expect(screen.getByTestId("native-login-error")).toHaveTextContent("false");
expect(
screen.getByRole("button", { name: "select-managed-account" }),
).toBeEnabled();
@@ -411,7 +531,7 @@ describe("ProviderForm Codex Official managed account", () => {
});
});
it("does not offer the current-login option on a managed Official card", () => {
it("does not offer the current-login option on a managed Official card", async () => {
const queryClient = createTestQueryClient();
render(
<QueryClientProvider client={queryClient}>
@@ -439,6 +559,7 @@ describe("ProviderForm Codex Official managed account", () => {
);
expect(screen.getByTestId("native-login-only")).toHaveTextContent("false");
await waitForNativeLoginLookup();
expect(
screen.queryByRole("button", { name: "select-native-login" }),
).not.toBeInTheDocument();
@@ -473,6 +594,7 @@ describe("ProviderForm Codex Official managed account", () => {
</QueryClientProvider>,
);
await waitForNativeLoginLookup();
fireEvent.click(
await screen.findByRole("button", { name: "select-native-login" }),
);
@@ -510,6 +632,7 @@ describe("ProviderForm Codex Official managed account", () => {
);
expect(screen.getByTestId("native-login-only")).toHaveTextContent("false");
await waitForNativeLoginLookup();
expect(
screen.queryByRole("button", { name: "select-native-login" }),
).not.toBeInTheDocument();
@@ -553,6 +676,7 @@ describe("ProviderForm Codex Official managed account", () => {
</QueryClientProvider>,
);
await waitForNativeLoginLookup();
const nativeLogin = await screen.findByRole("button", {
name: "select-native-login",
});