diff --git a/docs/design.md b/docs/design.md index 5d7f864..3a37e9b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -125,8 +125,10 @@ without a click. Codex CLI and the Codex desktop app update separately. Old percentages say "Last" and use muted meters; an unavailable result stays unknown. Actions stay in the account menu, not in the warning tooltip. Batch feedback describes quota results only. -- A saved email can be copied from its card menu. After a confirmed - authentication failure, the card shows **Sign in required** and makes +- A saved email can be copied from its card menu. Every saved ChatGPT account + also keeps **Sign in again** in that menu, so recovery never depends on + GSwitch diagnosing a failure correctly. After a confirmed authentication + failure, the card additionally shows **Sign in required** and makes **Sign in again** its primary action. The focused dialog shows the saved email and workspace, offers Copy email, and updates the original record only after verifying the returning identity. A saved login for the current account diff --git a/src/App.test.tsx b/src/App.test.tsx index 7d57c40..87476cd 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { open } from "@tauri-apps/plugin-dialog"; import App from "./App"; -import type { AccountView, QuotaView, SwitchFailureCode } from "./types"; +import type { AccountView, QuotaRefreshFailureCode, QuotaView, SwitchFailureCode } from "./types"; const mocks = vi.hoisted(() => ({ runtimeInfo: vi.fn(), @@ -862,7 +862,7 @@ describe("GSwitch account workspace", () => { expect(mocks.appSnapshot).toHaveBeenCalledOnce(); }); - it("keeps failed quota values visibly historical and offers account recovery only for a rejected sign-in", async () => { + it("keeps failed quota values visibly historical and makes sign-in the primary action for a rejected sign-in", async () => { const copyEmail = vi.fn().mockResolvedValue(undefined); Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: { writeText: copyEmail } }); mocks.listAccounts.mockResolvedValue([chatAccount]); @@ -1022,6 +1022,88 @@ describe("GSwitch account workspace", () => { ); }); + // Whether each failure makes Sign in again the card's primary action. The + // account menu must offer it after every failure, so recovery never depends + // on GSwitch having diagnosed the failure correctly. + const switchFailurePromotesSignIn = { + operation_busy: false, + codex_open: false, + account_needs_sign_in: true, + file_store_required: false, + credentials_changed: false, + recovery_required: false, + local_verification_failed: false, + codex_app_server_unavailable: false, + codex_config_unavailable: false, + codex_config_cleanup_failed: false, + current_credential_unreadable: false, + current_account_not_saved: false, + target_check_unavailable: false, + target_workspace_mismatch: false, + post_write_verification_failed: false, + verification_failed: false, + } satisfies Record; + + const quotaFailurePromotesSignIn = { + operation_busy: false, + codex_account_unknown: false, + authentication: true, + manual_refresh_needed: false, + rate_limited: false, + network: false, + service: false, + invalid_response: false, + identity_mismatch: false, + unavailable: false, + } satisfies Record; + + async function expectSignInAgainReachable(promoted: boolean) { + const card = screen.getByRole("heading", { name: "person@example.com" }).closest("article") as HTMLElement; + expect(within(card).queryAllByRole("button", { name: "Sign in again" })).toHaveLength(promoted ? 1 : 0); + expect(within(card).queryAllByRole("button", { name: "Switch to person@example.com" })).toHaveLength(promoted ? 0 : 1); + + await userEvent.click(within(card).getByRole("button", { name: "More actions for person@example.com" })); + const menu = card.querySelector(".card-menu-popover")!; + await userEvent.click(within(menu).getByRole("button", { name: "Sign in again" })); + await waitFor(() => expect(mocks.startOAuth).toHaveBeenCalledWith("account-1")); + expect(await screen.findByRole("dialog", { name: "Sign in again ยท person@example.com" })).toBeInTheDocument(); + } + + it.each(Object.entries(switchFailurePromotesSignIn) as Array<[SwitchFailureCode, boolean]>)( + "keeps Sign in again reachable after a %s switch failure", + async (code, promoted) => { + mocks.listAccounts.mockResolvedValue([chatAccount]); + mocks.switchAccount.mockRejectedValue({ code }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "Switch to person@example.com" })); + await screen.findByRole("alert"); + await expectSignInAgainReachable(promoted); + }, + ); + + it.each(Object.entries(quotaFailurePromotesSignIn) as Array<[QuotaRefreshFailureCode, boolean]>)( + "keeps Sign in again reachable after a %s quota failure", + async (code, promoted) => { + mocks.listAccounts.mockResolvedValue([chatAccount]); + mocks.accountQuota.mockResolvedValue(staleQuota); + mocks.refreshAccountQuota.mockRejectedValue({ code }); + render(); + + await screen.findByRole("button", { name: "Quota update failed for person@example.com" }); + await expectSignInAgainReachable(promoted); + }, + ); + + it("does not offer browser sign-in for an API-key account", async () => { + mocks.listAccounts.mockResolvedValue([{ id: "api-1", label: "Key", kind: "api_key", active: false }]); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "More actions for Key" })); + expect(screen.getByRole("button", { name: "Remove Key" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Sign in again" })).not.toBeInTheDocument(); + }); + it("updates the active account without reloading the workspace or adding a banner", async () => { const currentAccount: AccountView = { id: "account-2", @@ -1203,6 +1285,8 @@ describe("GSwitch account workspace", () => { await user.tab(); expect(screen.getByRole("button", { name: "Copy email" })).toHaveFocus(); await user.tab(); + expect(screen.getByRole("button", { name: "Sign in again" })).toHaveFocus(); + await user.tab(); expect(screen.getByRole("button", { name: "Remove person@example.com" })).toHaveFocus(); await user.tab(); expect(screen.queryByRole("button", { name: "Copy email" })).not.toBeInTheDocument(); diff --git a/src/App.tsx b/src/App.tsx index 9c7cf1a..c432474 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -739,6 +739,7 @@ function AccountCard({ label={t("account.moreActions", { name: primaryName })} > {account.email ? : null} + {!isApiKey ? : null}