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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { RefreshCw } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";

import Button from "@src/components/Button";
import { buildCodexReauthPath } from "@src/config/mainAppPaths";
import type { KeyVaultAccount } from "@src/hooks/keyVault";
import { useRefreshSpin } from "@src/hooks/ui";
import { AccountStatusIndicator } from "@src/modules/shared/keyVault/AccountStatusIndicator";

import { InlineCardFooter } from "../../shared/InlineCardPrimitives";
import { shouldShowCodexReconnect } from "./accountInlineActions";

interface AccountInlineActionsBarProps {
account: KeyVaultAccount;
Expand All @@ -34,6 +37,7 @@ export const AccountInlineActionsBar: React.FC<
}) => {
const { t } = useTranslation("integrations");
const { t: tCommon } = useTranslation();
const navigate = useNavigate();

const { spinClass, handleClick: handleRefreshClick } = useRefreshSpin(
onRefresh ?? (() => {}),
Expand All @@ -43,12 +47,23 @@ export const AccountInlineActionsBar: React.FC<
useRefreshSpin(onRefreshModels ?? (() => {}), refreshingModels);

const showEdit = !account.listingId && account.hasLocalKey && onEdit;
const showCodexReconnect = shouldShowCodexReconnect(account);
const resolvedRefreshLabel = refreshLabel ?? tCommon("actions.refresh");
return (
<InlineCardFooter>
<div className="mr-auto flex min-h-7 items-center">
<AccountStatusIndicator account={account} />
</div>
{showCodexReconnect ? (
<Button
variant="primary"
size="small"
onClick={() => navigate(buildCodexReauthPath(account.id))}
title={tCommon("errors.reconnectCodex")}
>
{tCommon("errors.reconnectCodex")}
</Button>
) : null}
{onRefresh ? (
<Button
variant="secondary"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Test Cases: AccountInlineActionsBar Codex Reconnect

## Preconditions

- Open Settings → Integrations → Models & Keys.
- At least one local Codex OAuth account is available.
- The account list has finished loading.

## Happy Path

| # | Steps | Expected Result |
| --- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| 1 | Expand a Codex OAuth account whose status is invalid. | A “Reconnect Codex” button is visible in the account footer. |
| 2 | Activate “Reconnect Codex”. | The existing-account repair wizard opens and the embedded Codex login starts automatically. |
| 3 | Complete ChatGPT login and save. | The existing account is updated, returns to Models & Keys, and becomes available to the model picker. |

## Edge Cases

| # | Scenario | Steps | Expected Result |
| --- | --------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| 1 | Healthy Codex OAuth account | Expand an enabled, healthy Codex account. | No reconnect button is shown. |
| 2 | Codex API-key account | Expand an invalid Codex row authenticated with an API key. | No OAuth reconnect button is shown; existing edit/removal actions remain available. |
| 3 | Other invalid provider | Expand an invalid Claude Code or API-provider account. | No Codex reconnect button is shown. |
| 4 | Non-local account | Inspect an invalid account without local credential material. | No reconnect button is shown. |
| 5 | Rapid repeated interaction | Click the reconnect button repeatedly before navigation settles. | Navigation remains on one repair wizard and does not create duplicate credentials. |
| 6 | Narrow panel | Resize the Settings panel to its minimum supported width. | Footer actions remain usable and the reconnect label does not overlap account status. |

## Error / Degraded States

| # | Scenario | Steps | Expected Result |
| --- | ---------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 1 | Browser login cancelled | Open reconnect, then close the embedded browser. | No replacement token is saved and the original invalid account remains intact. |
| 2 | OAuth login rejected | Complete login with an upstream authorization error. | The wizard displays the existing Codex sign-in error and does not create a duplicate account. |
| 3 | Account removed before click | Remove the credential in another window, then activate reconnect. | The wizard opens in repair mode and reports the missing account without overwriting another row. |

## Accessibility

- [ ] Reconnect is keyboard-navigable with Tab and Enter/Space.
- [ ] The button exposes the localized “Reconnect Codex” accessible name.
- [ ] Focus moves into the existing repair wizard when navigation completes.
- [ ] Embedded login retains its existing focus and Escape/close behavior.

## Acceptance Criteria

- [ ] Only failed local Codex OAuth accounts show “Reconnect Codex”.
- [ ] Activating reconnect passes the existing account ID to the repair route.
- [ ] Successful login updates the existing credential instead of creating a duplicate.
- [ ] Healthy, API-key, non-Codex, and non-local accounts do not show the action.
- [ ] Existing refresh, edit, and remove actions continue to work.
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";

import type { KeyVaultAccount } from "@src/hooks/keyVault";

import { shouldShowCodexReconnect } from "../accountInlineActions";

function createAccount(
overrides: Partial<KeyVaultAccount> = {}
): KeyVaultAccount {
return {
id: "codex-account",
hasLocalKey: true,
isListed: false,
modelType: "codex",
name: "Codex",
status: "error",
hasKey: true,
hasApiKey: false,
hasSessionToken: true,
authMethod: "oauth",
enabled: false,
healthStatus: "invalid",
...overrides,
};
}

describe("shouldShowCodexReconnect", () => {
it("shows reconnect for a failed local Codex OAuth account", () => {
expect(shouldShowCodexReconnect(createAccount())).toBe(true);
});

it("accepts invalid health even before the mapped status becomes error", () => {
expect(
shouldShowCodexReconnect(
createAccount({ status: "ready", healthStatus: "invalid" })
)
).toBe(true);
});

it("hides reconnect for a healthy Codex OAuth account", () => {
expect(
shouldShowCodexReconnect(
createAccount({ status: "ready", healthStatus: "valid", enabled: true })
)
).toBe(false);
});

it("hides browser reauthentication for a Codex API-key account", () => {
expect(
shouldShowCodexReconnect(
createAccount({ authMethod: "api_key", hasApiKey: true })
)
).toBe(false);
});

it("hides reconnect for other invalid providers", () => {
expect(
shouldShowCodexReconnect(createAccount({ modelType: "claude_code" }))
).toBe(false);
});

it("hides reconnect when the credential is not stored locally", () => {
expect(
shouldShowCodexReconnect(createAccount({ hasLocalKey: false }))
).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { KeyVaultAccount } from "@src/hooks/keyVault";

type ReconnectableAccount = Pick<
KeyVaultAccount,
"authMethod" | "hasLocalKey" | "healthStatus" | "modelType" | "status"
>;

/**
* Codex browser reauthentication updates an existing local OAuth credential.
* API-key rows and manually disabled healthy rows use their existing edit flow.
*/
export function shouldShowCodexReconnect(
account: ReconnectableAccount
): boolean {
return (
account.hasLocalKey &&
account.modelType === "codex" &&
account.authMethod === "oauth" &&
(account.status === "error" || account.healthStatus === "invalid")
);
}