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() { - - -
-

{t("add.addNew")}

- -
-
- {t("add.otherMethods")} -
- - -
-
- + void startOAuth()} + onImport={() => void chooseImportFile()} + onSelectMethod={openAddDialog} + t={t} + /> ) : null} {addMethod === "migration" ? ( @@ -2627,7 +2620,7 @@ export default function App() {
- +
) : oauth.status.status === "finishing" ? ( diff --git a/src/i18n.ts b/src/i18n.ts index b990fbc..2540bce 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -86,11 +86,7 @@ const en = { "credit.expired": "Expired", "firstRun.eyebrow": "GET STARTED", "firstRun.title": "Add your Codex accounts", - "firstRun.body": "Import account files or sign in. GSwitch scans this computer only when you choose to.", - "firstRun.importExport": "Import account files", - "firstRun.addManually": "Add manually", - "firstRun.browserSignIn": "Browser sign-in", - "firstRun.authJson": "Cockpit Tools export, Codex auth.json, or another supported account export", + "firstRun.body": "Sign in or import your Codex accounts.", "toolbar.language": "Language", "toolbar.codexCli": "Codex CLI", "toolbar.wakeAll": "Wake all", @@ -230,10 +226,8 @@ const en = { "accounts.selectAccount": "Select {name}", "accounts.selectionActions": "Selected account actions", "add.title": "Add a Codex account", - "add.importExisting": "Import existing", - "add.addNew": "Add new", "add.otherMethods": "Other methods", - "add.browserTitle": "Sign in to add an account", + "add.browserTitle": "Sign in", "add.browserDescription": "Use your browser to sign in; GSwitch saves the verified account.", "add.jsonTitle": "Paste auth JSON", "add.jsonDescription": "Submit a complete Codex auth document.", @@ -454,12 +448,8 @@ const zhCN: Record = { "credit.earliest": "最早到期的重置额度:{time}", "credit.expired": "已到期", "firstRun.eyebrow": "开始使用", - "firstRun.title": "添加 Codex 账户", - "firstRun.body": "导入账户文件,或通过浏览器登录。只有你选择扫描时,GSwitch 才会查找本机账户。", - "firstRun.importExport": "导入账户文件", - "firstRun.addManually": "手动添加", - "firstRun.browserSignIn": "浏览器登录", - "firstRun.authJson": "Cockpit Tools 导出、Codex auth.json 或其他受支持的账户导出文件", + "firstRun.title": "添加你的 Codex 账户", + "firstRun.body": "登录或导入你的 Codex 账户。", "toolbar.language": "语言", "toolbar.codexCli": "Codex CLI", "toolbar.wakeAll": "全部唤醒", @@ -599,10 +589,8 @@ const zhCN: Record = { "accounts.selectAccount": "选择 {name}", "accounts.selectionActions": "所选账户操作", "add.title": "添加 Codex 账户", - "add.importExisting": "导入已有账户", - "add.addNew": "添加新账户", "add.otherMethods": "其他方式", - "add.browserTitle": "登录并添加账户", + "add.browserTitle": "登录", "add.browserDescription": "在浏览器中登录,GSwitch 会保存验证后的账户。", "add.jsonTitle": "粘贴 auth JSON", "add.jsonDescription": "提交完整的 Codex auth 文档。", @@ -610,7 +598,7 @@ const zhCN: Record = { "add.fileDescription": "选择一个或多个账户文件,包括 Cockpit Tools 导出和 Codex auth.json", "add.apiKeyTitle": "添加 API 密钥", "add.apiKeyDescription": "保存密钥,不进行测试。", - "add.localTitle": "在此电脑上查找", + "add.localTitle": "从本机查找", "add.localDescription": "点击扫描,预览这台电脑上的 Codex 和 Cockpit Tools 账户。", "add.cockpitHelper": "你可以从 Cockpit Tools 导出账户文件,也可以在导入预览中扫描其文档化的本地账户存储。", "migration.title": "从此电脑导入", diff --git a/src/styles.css b/src/styles.css index 0a9243f..5b8a95b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1054,8 +1054,8 @@ summary:focus-visible { } .first-run { - display: grid; - grid-template-columns: minmax(0, 1.25fr) minmax(250px, 0.75fr); + width: min(100%, 600px); + margin-inline: auto; overflow: hidden; border: 1px solid var(--border); border-radius: 15px; @@ -1064,7 +1064,7 @@ summary:focus-visible { } .first-run-primary { - padding: clamp(28px, 4vw, 44px); + padding: clamp(24px, 3vw, 32px); } .first-run h2 { @@ -1082,39 +1082,9 @@ summary:focus-visible { line-height: 1.55; } -.first-run-actions { - display: flex; - flex-wrap: wrap; - gap: 9px; +.first-run .add-methods { margin-top: 23px; -} - -.first-run-methods { - display: grid; - align-content: center; - gap: 0; - padding: 18px clamp(20px, 3vw, 32px); - border-left: 1px solid var(--border); - background: var(--surface-soft); -} - -.first-run-methods div { - display: flex; - min-height: 72px; - align-items: center; - gap: 11px; - border-bottom: 1px solid var(--border); - color: var(--muted); - font-size: 12px; - font-weight: 620; -} - -.first-run-methods div:last-child { - border-bottom: 0; -} - -.first-run-methods svg { - color: var(--accent); + padding: 0; } .modal-backdrop { @@ -1225,21 +1195,7 @@ summary:focus-visible { .add-methods { display: grid; - gap: 14px; -} - -.add-method-group { - display: grid; - gap: 8px; -} - -.add-method-group h3 { - margin: 0 2px; - color: var(--soft-muted); - font-size: 10.5px; - font-weight: 760; - letter-spacing: 0.08em; - text-transform: uppercase; + gap: 10px; } .add-other-methods { @@ -1275,7 +1231,7 @@ summary:focus-visible { text-align: left; } -.add-method-card:hover { +.add-method-card:hover:not(:disabled) { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); background: var(--surface-hover); } @@ -1311,6 +1267,29 @@ summary:focus-visible { color: var(--soft-muted); } +.add-method-sign-in { + --accent: #535fe7; + --accent-hover: #434ed6; + border-color: var(--accent); + color: white; + background: var(--accent); +} + +.add-method-sign-in:hover:not(:disabled) { + border-color: var(--accent-hover); + background: var(--accent-hover); +} + +.add-method-sign-in small, +.add-method-sign-in > svg { + color: white; +} + +.add-method-sign-in .method-icon { + color: white; + background: rgba(255, 255, 255, 0.16); +} + .dialog-footnote { margin: 8px 2px 0; color: var(--soft-muted); @@ -1861,14 +1840,6 @@ textarea { justify-content: flex-start; } - .first-run { - grid-template-columns: 1fr; - } - - .first-run-methods { - border-top: 1px solid var(--border); - border-left: 0; - } } @media (max-width: 510px) {