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
6 changes: 4 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 86 additions & 2 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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<SwitchFailureCode, boolean>;

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<QuotaRefreshFailureCode, boolean>;

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<HTMLElement>(".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(<App />);

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(<App />);

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(<App />);

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",
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,7 @@ function AccountCard({
label={t("account.moreActions", { name: primaryName })}
>
{account.email ? <button onClick={onCopyEmail} type="button"><Copy size={15} />{t("account.copyEmail")}</button> : null}
{!isApiKey ? <button disabled={controlsBusy} onClick={onReauthenticate} type="button"><Globe2 size={15} />{t("account.signInAgain")}</button> : null}
<button
className="card-menu-danger"
aria-label={t("account.remove", { name: primaryName })}
Expand Down
Loading