From 3d9d1a6d1f0869dca466583fb90c62f13febba3a Mon Sep 17 00:00:00 2001
From: squarepots <46488165+squarepots@users.noreply.github.com>
Date: Sun, 4 Oct 2026 18:36:28 +0800
Subject: [PATCH] Simplify first-use account setup and browser icons
---
docs/design.md | 12 +++--
docs/testing.md | 7 ++-
src/App.test.tsx | 88 ++++++++++++++++++++++--------
src/App.tsx | 137 ++++++++++++++++++++++-------------------------
src/i18n.ts | 24 +++------
src/styles.css | 89 +++++++++++-------------------
6 files changed, 180 insertions(+), 177 deletions(-)
diff --git a/docs/design.md b/docs/design.md
index 550a993..3a9111d 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -67,11 +67,13 @@ credits are listed soonest-expiring first, and a note counts any the provider
did not list. The dialog title names the account email, adding the workspace
only when the email alone is ambiguous.
-The empty state is two deliberate choices: import one or more export files the
-user selects, or add an account manually. The add dialog groups the normal path
-as **Import existing** (Find on this computer and Choose files) then **Add
-new** (official Codex sign-in). Pasted auth JSON and API key remain an **Other
-methods** disclosure. One concise privacy note says that GSwitch reads only
+The empty state and the add dialog share the same choices: a prominent
+**Sign in** action starts official Codex browser sign-in directly, followed by
+**Find on this computer** and **Choose files**. Pasted auth JSON and API key
+remain an **Other methods** disclosure. Browser actions use a tabbed-window
+icon; the globe belongs only to the language menu. The notice for an unsaved
+current account retains its **Save current account** shortcut. One concise
+privacy note says that GSwitch reads only
accounts the user chooses to import and does not change their source. The local
preview explains its allowlist before scanning and keeps already-saved
identities disabled.
diff --git a/docs/testing.md b/docs/testing.md
index e0be1d7..72014e4 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -254,8 +254,11 @@ as applicable:
selections, cancellation before write, Unix private permissions, version-1
import round-trip, unknown-version rejection, saved-identity deduplication,
and aggregate-only command output;
-- Add Account import-first hierarchy, account selection controls, selected Wake,
- unencrypted-export confirmation, and result feedback without credential data.
+- shared first-use and Add Account choices, direct browser-sign-in launch,
+ disabled choices during launch, launch failure and cancellation, collapsed
+ other methods, explicit scanning and file selection, and the shortcut to save
+ the current account; account selection controls, selected Wake, unencrypted-export
+ confirmation, and result feedback without credential data.
- the toolbar's centered brand without a duplicate current-account label,
current-card identification, signed-out and recovery notices, theme-aware
language icon, plus the canonical SVG and generated native/installer icon dimensions.
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 041a816..68aa9d5 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -291,8 +291,9 @@ describe("GSwitch account workspace", () => {
expect(await screen.findByRole("heading", { name: "已保存 0 个账户" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "添加账户" }));
- expect(await screen.findByRole("dialog", { name: "添加 Codex 账户" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: /粘贴 auth JSON/ })).toBeInTheDocument();
+ const dialog = await screen.findByRole("dialog", { name: "添加 Codex 账户" });
+ await userEvent.click(within(dialog).getByText("其他方式"));
+ expect(within(dialog).getByRole("button", { name: /粘贴 auth JSON/ })).toBeVisible();
});
it("applies and remembers a manual language choice immediately", async () => {
@@ -315,28 +316,76 @@ describe("GSwitch account workspace", () => {
expect(screen.getByRole("button", { name: "完成" })).toHaveAttribute("aria-pressed", "true");
});
- it("guides a first-time user to an explicit import or manual add", async () => {
+ it("offers the same sign-in and import choices on first use and in Add account", async () => {
render();
expect(await screen.findByRole("heading", { name: "0 saved accounts" })).toBeInTheDocument();
- expect(screen.getByText(/scans this computer only when you choose to/i)).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Import account files" })).toBeInTheDocument();
+ const firstRun = screen.getByRole("heading", { name: "Add your Codex accounts" }).closest("section")!;
+ expect(within(firstRun).getAllByRole("button").slice(0, 3).map((button) => button.querySelector("strong")?.textContent))
+ .toEqual(["Sign in", "Find on this computer", "Choose files"]);
+ expect(within(firstRun).getByText("Paste auth JSON")).not.toBeVisible();
+ expect(screen.queryByText("Add manually")).not.toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "Add manually" }));
+ await userEvent.click(screen.getByRole("button", { name: "Add account" }));
const dialog = await screen.findByRole("dialog", { name: "Add a Codex account" });
- expect(dialog).toBeInTheDocument();
+ expect(within(dialog).getAllByRole("button").slice(1, 4).map((button) => button.querySelector("strong")?.textContent))
+ .toEqual(["Sign in", "Find on this computer", "Choose files"]);
+ expect(within(dialog).getByText("Paste auth JSON")).not.toBeVisible();
+ await userEvent.click(within(dialog).getByText("Other methods"));
expect(within(dialog).getByRole("button", { name: /Paste auth JSON/ })).toBeInTheDocument();
- expect(within(dialog).getByText("Import existing")).toBeInTheDocument();
- expect(within(dialog).getByText("Add new")).toBeInTheDocument();
+ expect(within(dialog).queryByText("Import existing")).not.toBeInTheDocument();
+ expect(within(dialog).queryByText("Add new")).not.toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: /Choose files/ })).toBeInTheDocument();
expect(within(dialog).getByText(/Select one or more account files/i)).toBeInTheDocument();
});
+ it("starts browser sign-in directly from first use and blocks another start while pending", async () => {
+ let finishStart!: (value: { login_id: string; auth_url: string }) => void;
+ mocks.startOAuth.mockImplementation(() => new Promise((resolve) => { finishStart = resolve; }));
+ render();
+ const signIn = await screen.findByRole("button", { name: /^Sign in / });
+ await userEvent.dblClick(signIn);
+ expect(mocks.startOAuth).toHaveBeenCalledExactlyOnceWith(undefined);
+ expect(signIn).toBeDisabled();
+ expect(screen.getByRole("button", { name: /^Find on this computer/ })).toBeDisabled();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+
+ finishStart({ login_id: "login-1", auth_url: "https://example.com/sign-in" });
+ expect(await screen.findByText("Finish sign-in in your browser")).toBeInTheDocument();
+ expect(mocks.openOAuth).not.toHaveBeenCalled();
+ expect(mocks.discoverLocalAccounts).not.toHaveBeenCalled();
+ expect(mocks.importAuthFiles).not.toHaveBeenCalled();
+ });
+
+ it("keeps first-use choices available when browser sign-in cannot start", async () => {
+ mocks.startOAuth.mockRejectedValue("network");
+ render();
+ await userEvent.click(await screen.findByRole("button", { name: /^Sign in / }));
+ expect(await screen.findByRole("alert")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /^Sign in / })).toBeEnabled();
+ expect(screen.getByRole("button", { name: /^Choose files/ })).toBeEnabled();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it("opens other methods directly from the Chinese first-use screen", async () => {
+ Object.defineProperty(window.navigator, "language", { configurable: true, value: "zh-CN" });
+ render();
+ expect(await screen.findByRole("heading", { name: "添加你的 Codex 账户" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /^登录 / })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /^从本机查找/ })).toBeInTheDocument();
+ await userEvent.click(screen.getByText("其他方式"));
+ await userEvent.click(screen.getByRole("button", { name: /^粘贴 auth JSON/ }));
+ const dialog = await screen.findByRole("dialog", { name: "添加 Codex 账户" });
+ expect(within(dialog).getByRole("textbox", { name: "auth.json" })).toBeInTheDocument();
+ expect(mocks.startOAuth).not.toHaveBeenCalled();
+ expect(mocks.discoverLocalAccounts).not.toHaveBeenCalled();
+ });
+
it("keeps keyboard focus in a dialog and returns it to the launcher", async () => {
const user = userEvent.setup();
render();
await screen.findByRole("heading", { name: "0 saved accounts" });
- const launcher = screen.getByRole("button", { name: "Add manually" });
+ const launcher = screen.getByRole("button", { name: "Add account" });
await user.click(launcher);
const dialog = screen.getByRole("dialog", { name: "Add a Codex account" });
expect(dialog).toHaveFocus();
@@ -430,7 +479,6 @@ describe("GSwitch account workspace", () => {
expect(await screen.findByRole("heading", { name: "0 saved accounts" })).toBeInTheDocument();
expect(mocks.discoverLocalAccounts).not.toHaveBeenCalled();
- await userEvent.click(screen.getByRole("button", { name: "Add manually" }));
await userEvent.click(screen.getByRole("button", { name: /Find on this computer/ }));
expect(screen.getByText(/Nothing is imported until you select accounts/i)).toBeInTheDocument();
expect(mocks.discoverLocalAccounts).not.toHaveBeenCalled();
@@ -463,8 +511,7 @@ describe("GSwitch account workspace", () => {
});
render();
- await userEvent.click(await screen.findByRole("button", { name: "Add manually" }));
- await userEvent.click(screen.getByRole("button", { name: /Find on this computer/ }));
+ await userEvent.click(await screen.findByRole("button", { name: /Find on this computer/ }));
await userEvent.click(screen.getByRole("button", { name: "Scan supported locations" }));
expect(await screen.findByText("person@example.com")).toBeInTheDocument();
@@ -485,8 +532,7 @@ describe("GSwitch account workspace", () => {
mocks.discoverLocalAccounts.mockResolvedValue({ candidates: [] });
render();
- await userEvent.click(await screen.findByRole("button", { name: "Add manually" }));
- await userEvent.click(screen.getByRole("button", { name: /Find on this computer/ }));
+ await userEvent.click(await screen.findByRole("button", { name: /Find on this computer/ }));
await userEvent.click(screen.getByRole("button", { name: "Choose another Cockpit folder" }));
await waitFor(() => expect(mocks.discoverLocalAccounts).toHaveBeenCalledWith("C:\\custom\\cockpit"));
@@ -503,7 +549,7 @@ describe("GSwitch account workspace", () => {
});
render();
- await userEvent.click(await screen.findByRole("button", { name: "Import account files" }));
+ await userEvent.click(await screen.findByRole("button", { name: /^Choose files/ }));
await waitFor(() =>
expect(mocks.importAuthFiles).toHaveBeenCalledWith([
"C:\\exports\\one.json",
@@ -518,7 +564,7 @@ describe("GSwitch account workspace", () => {
vi.mocked(open).mockResolvedValue(["C:\\exports\\one.json"]);
render();
- await userEvent.click(await screen.findByRole("button", { name: "Import account files" }));
+ await userEvent.click(await screen.findByRole("button", { name: /^Choose files/ }));
await waitFor(() => expect(mocks.importAuthFiles).toHaveBeenCalledWith(["C:\\exports\\one.json"]));
});
@@ -538,7 +584,7 @@ describe("GSwitch account workspace", () => {
mocks.refreshAccountQuota.mockRejectedValue(new Error("quota cache unavailable"));
render();
- await userEvent.click(await screen.findByRole("button", { name: "Import account files" }));
+ await userEvent.click(await screen.findByRole("button", { name: /^Choose files/ }));
expect(await screen.findByRole("heading", { name: "person@example.com" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Switch to person@example.com" })).toBeEnabled();
});
@@ -1644,7 +1690,7 @@ describe("GSwitch account workspace", () => {
await userEvent.click(screen.getByRole("button", { name: /^Add account$/ }));
const dialog = await screen.findByRole("dialog", { name: "Add a Codex account" });
- await userEvent.click(within(dialog).getByRole("button", { name: /Sign in to add an account/ }));
+ await userEvent.click(within(dialog).getByRole("button", { name: /^Sign in / }));
expect(await screen.findByText("Finish sign-in in your browser")).toBeInTheDocument();
expect(mocks.openOAuth).not.toHaveBeenCalled();
@@ -1659,7 +1705,7 @@ describe("GSwitch account workspace", () => {
.mockResolvedValue({ status: "complete", account: chatAccount });
render();
await userEvent.click(await screen.findByRole("button", { name: /^Add account$/ }));
- await userEvent.click(screen.getByRole("button", { name: /Sign in to add an account/ }));
+ await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: /^Sign in / }));
expect(await screen.findByText(/cannot check the sign-in result right now/i)).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: "Add a Codex account" })).toBeInTheDocument();
@@ -1676,7 +1722,7 @@ describe("GSwitch account workspace", () => {
.mockResolvedValue({ status: "complete", account: { ...chatAccount, needs_apply: true }, cleanup_warning: false });
render();
await userEvent.click(await screen.findByRole("button", { name: /^Add account$/ }));
- await userEvent.click(screen.getByRole("button", { name: /Sign in to add an account/ }));
+ await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: /^Sign in / }));
expect(await screen.findByText("Finishing sign-in…")).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "Retry saving" }));
expect(mocks.retryOAuth).toHaveBeenCalledWith("login-1");
diff --git a/src/App.tsx b/src/App.tsx
index cf84d25..e887260 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,6 +1,7 @@
import { open } from "@tauri-apps/plugin-dialog";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import {
+ AppWindow,
ArrowRightLeft,
Check,
ChevronRight,
@@ -10,7 +11,6 @@ import {
Download,
FileJson,
FolderOpen,
- Globe2,
Info,
KeyRound,
ListChecks,
@@ -909,7 +909,7 @@ function AccountCard({
label={t("account.moreActions", { name: primaryName })}
>
{account.email ? : null}
- {!isApiKey ? : null}
+ {!isApiKey ? : null}
@@ -1019,7 +1019,53 @@ function AccountCard({
);
}
-function FirstRun({ onImport, onAdd, t }: { onImport: () => void; onAdd: () => void; t: Translator }) {
+interface AddAccountMethodsProps {
+ busy: boolean;
+ onSignIn: () => void;
+ onImport: () => void;
+ onSelectMethod: (method: "migration" | "json" | "api-key") => void;
+ t: Translator;
+}
+
+function AddAccountMethods({ busy, onSignIn, onImport, onSelectMethod, t }: AddAccountMethodsProps) {
+ return (
+
+
+
+
+
+ {t("add.otherMethods")}
+
+
+
+
+
+
+ );
+}
+
+function FirstRun(props: AddAccountMethodsProps) {
+ const { t } = props;
return (
@@ -1028,21 +1074,7 @@ function FirstRun({ onImport, onAdd, t }: { onImport: () => void; onAdd: () => v
{t("firstRun.body")}
-
-
-
-
-
-
-
{t("firstRun.browserSignIn")}
-
{t("firstRun.authJson")}
-
{t("account.apiKey")}
+
);
@@ -1770,11 +1802,11 @@ export default function App() {
setDialog(null);
};
- const openAddDialog = () => {
+ const openAddDialog = (method: AddMethod = "start") => {
setOauth(null);
setOauthStatusUnavailable(false);
setOauthTarget(null);
- setAddMethod("start");
+ setAddMethod(method);
setMigrationPreview(null);
setMigrationRoot(undefined);
setMigrationSelection([]);
@@ -2206,7 +2238,7 @@ export default function App() {