From f3909084f6e2f08b70a4f18fd0f6d5a366ad27fd Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 27 Sep 2026 09:03:38 -0700 Subject: [PATCH] =?UTF-8?q?Marketplace=20UI:=20accounts=20=E2=80=94=20gh?= =?UTF-8?q?=20on=20host,=20gh=20in=20a=20container,=20access=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per preflight F5, Remove is disabled with a hint for an account a marketplace uses rather than offering a confirm modal that promises a removal the backend refuses. Co-Authored-By: Claude Opus 5.5 --- .../marketplace/AccountsPane.test.tsx | 75 +++++++ .../components/marketplace/AccountsPane.tsx | 105 +++++++++- .../marketplace/AddAccountModal.test.tsx | 77 +++++++ .../marketplace/AddAccountModal.tsx | 190 ++++++++++++++++++ .../GhContainerLoginModal.test.tsx | 72 +++++++ .../marketplace/GhContainerLoginModal.tsx | 158 +++++++++++++++ 6 files changed, 673 insertions(+), 4 deletions(-) create mode 100644 app/src/components/marketplace/AccountsPane.test.tsx create mode 100644 app/src/components/marketplace/AddAccountModal.test.tsx create mode 100644 app/src/components/marketplace/AddAccountModal.tsx create mode 100644 app/src/components/marketplace/GhContainerLoginModal.test.tsx create mode 100644 app/src/components/marketplace/GhContainerLoginModal.tsx diff --git a/app/src/components/marketplace/AccountsPane.test.tsx b/app/src/components/marketplace/AccountsPane.test.tsx new file mode 100644 index 0000000..5c5b3bb --- /dev/null +++ b/app/src/components/marketplace/AccountsPane.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useAppState } from "../../store/appState"; +import type { AppSettings } from "../../lib/types"; +import type { MarketplaceApi } from "../../hooks/useMarketplace"; + +const testMarketplaceAccount = vi.fn(); +const removeMarketplaceAccount = vi.fn(); +vi.mock("../../lib/tauri-commands", () => ({ + testMarketplaceAccount: (id: string) => testMarketplaceAccount(id), + removeMarketplaceAccount: (id: string) => removeMarketplaceAccount(id), +})); +vi.mock("./AddAccountModal", () => ({ default: () =>
add account modal
})); + +import AccountsPane from "./AccountsPane"; + +const settings = { + marketplace_accounts: [ + { id: "a1", label: "Personal", host: "github.com", method: "gh_host", username: "me" }, + { id: "a2", label: "Gitea", host: "repo.example.com", method: "token", username: "jk" }, + ], + marketplaces: [{ id: "m1", name: "Team", url: "https://repo.example.com/t/m.git", branch: null, account_id: "a2" }], + global_marketplace_installs: [], +} as unknown as AppSettings; + +describe("AccountsPane", () => { + beforeEach(() => { + vi.clearAllMocks(); + useAppState.setState({ appSettings: settings, toasts: [] }); + }); + + it("lists accounts with their method and usage", () => { + render(); + expect(screen.getByText("Personal")).toBeInTheDocument(); + expect(screen.getByText(/gh on this computer/)).toBeInTheDocument(); + expect(screen.getByText(/Used by Team$/)).toBeInTheDocument(); + }); + + it("tests an account", async () => { + testMarketplaceAccount.mockResolvedValue("me"); + render(); + fireEvent.click(screen.getByRole("button", { name: "Test Personal" })); + await waitFor(() => expect(useAppState.getState().toasts[0]).toMatchObject({ kind: "success" })); + expect(useAppState.getState().toasts[0].message).toContain("me"); + }); + + // F5: the backend refuses to remove an account a marketplace uses, so the + // UI must not promise otherwise with a confirm modal — Remove is disabled + // with a hint instead, and there is no confirm step to click through. + it("disables Remove for an account in use, with a hint", () => { + render(); + const removeGitea = screen.getByRole("button", { name: "Remove Gitea" }); + expect(removeGitea).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText(/Used by Team.*change or remove that marketplace first/)).toBeInTheDocument(); + fireEvent.click(removeGitea); + expect(removeMarketplaceAccount).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("removes an unused account", async () => { + removeMarketplaceAccount.mockResolvedValue({ ...settings, marketplace_accounts: [settings.marketplace_accounts[1]] }); + render(); + const removePersonal = screen.getByRole("button", { name: "Remove Personal" }); + expect(removePersonal).not.toHaveAttribute("aria-disabled"); + fireEvent.click(removePersonal); + await waitFor(() => expect(removeMarketplaceAccount).toHaveBeenCalledWith("a1")); + await waitFor(() => expect(useAppState.getState().appSettings!.marketplace_accounts).toHaveLength(1)); + }); + + it("opens the add dialog", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Add account" })); + expect(screen.getByText("add account modal")).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/marketplace/AccountsPane.tsx b/app/src/components/marketplace/AccountsPane.tsx index 41fcb1c..6f28255 100644 --- a/app/src/components/marketplace/AccountsPane.tsx +++ b/app/src/components/marketplace/AccountsPane.tsx @@ -1,11 +1,108 @@ +import { useState } from "react"; import type { MarketplaceApi } from "../../hooks/useMarketplace"; import { useAppState } from "../../store/appState"; +import { removeMarketplaceAccount, testMarketplaceAccount } from "../../lib/tauri-commands"; +import type { AccountMethod, MarketplaceAccount } from "../../lib/types"; +import Button from "../ui/Button"; +import AddAccountModal from "./AddAccountModal"; + +const METHOD_LABEL: Record = { + gh_host: "GitHub — gh on this computer", + gh_container: "GitHub — signed in via container", + token: "Access token", +}; export default function AccountsPane(_props: { mp: MarketplaceApi }) { - const count = useAppState((s) => s.appSettings?.marketplace_accounts.length ?? 0); + const appSettings = useAppState((s) => s.appSettings); + const setAppSettings = useAppState((s) => s.setAppSettings); + const pushToast = useAppState((s) => s.pushToast); + const [adding, setAdding] = useState(false); + const [testing, setTesting] = useState(null); + const [removing, setRemoving] = useState(null); + + const accounts = appSettings?.marketplace_accounts ?? []; + const marketplaces = appSettings?.marketplaces ?? []; + const usedBy = (id: string) => marketplaces.filter((m) => m.account_id === id).map((m) => m.name); + + const test = async (a: MarketplaceAccount) => { + setTesting(a.id); + try { + const login = await testMarketplaceAccount(a.id); + pushToast({ kind: "success", message: `${a.label} works — signed in as ${login}` }); + } catch (e) { + pushToast({ kind: "error", message: `${a.label} could not sign in`, detail: String(e) }); + } finally { + setTesting(null); + } + }; + + const remove = async (a: MarketplaceAccount) => { + setRemoving(a.id); + try { + setAppSettings(await removeMarketplaceAccount(a.id)); + } catch (e) { + pushToast({ kind: "error", message: `Could not remove ${a.label}`, detail: String(e) }); + } finally { + setRemoving(null); + } + }; + return ( -

- {count} account{count === 1 ? "" : "s"}. -

+
+
+

+ Accounts are used to fetch private marketplaces. Tokens are kept in your OS keychain and never enter + containers. +

+ +
+ {accounts.length === 0 &&

No accounts yet. Public repositories need none.

} +
    + {accounts.map((a) => { + const users = usedBy(a.id); + const inUse = users.length > 0; + return ( +
  • +
    +

    {a.label}

    +

    + {METHOD_LABEL[a.method]} · {a.host} + {a.username ? ` · ${a.username}` : ""} +

    + {inUse &&

    Used by {users.join(", ")}

    } +
    +
    + + +
    +
  • + ); + })} +
+ {adding && setAdding(false)} />} +
); } diff --git a/app/src/components/marketplace/AddAccountModal.test.tsx b/app/src/components/marketplace/AddAccountModal.test.tsx new file mode 100644 index 0000000..a00e4a0 --- /dev/null +++ b/app/src/components/marketplace/AddAccountModal.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useAppState } from "../../store/appState"; +import type { Project } from "../../lib/types"; + +const marketplaceGhHostAvailable = vi.fn(); +const addMarketplaceGhHostAccount = vi.fn(); +const addMarketplaceTokenAccount = vi.fn(); +const getSettings = vi.fn(); +vi.mock("../../lib/tauri-commands", () => ({ + marketplaceGhHostAvailable: () => marketplaceGhHostAvailable(), + addMarketplaceGhHostAccount: (...a: unknown[]) => addMarketplaceGhHostAccount(...a), + addMarketplaceTokenAccount: (...a: unknown[]) => addMarketplaceTokenAccount(...a), + getSettings: () => getSettings(), +})); +vi.mock("./GhContainerLoginModal", () => ({ + default: ({ projectId }: { projectId: string }) =>
container login for {projectId}
, +})); + +import AddAccountModal from "./AddAccountModal"; + +const running = { id: "p1", name: "api", status: "running", container_id: "c1" } as unknown as Project; + +describe("AddAccountModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSettings.mockResolvedValue({ marketplace_accounts: [] }); + useAppState.setState({ projects: [running], toasts: [] }); + }); + + it("uses host gh when available", async () => { + marketplaceGhHostAvailable.mockResolvedValue(true); + addMarketplaceGhHostAccount.mockResolvedValue({ id: "a1" }); + const onClose = vi.fn(); + render(); + expect(await screen.findByText(/gh is installed on this computer/)).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Personal" } }); + fireEvent.click(screen.getByRole("button", { name: "Add account" })); + await waitFor(() => expect(addMarketplaceGhHostAccount).toHaveBeenCalledWith("Personal", "github.com")); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it("falls back to gh in a running container", async () => { + marketplaceGhHostAvailable.mockResolvedValue(false); + render(); + expect(await screen.findByLabelText("Run gh in")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Work" } }); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + expect(screen.getByText("container login for p1")).toBeInTheDocument(); + }); + + it("adds a token account for any host", async () => { + marketplaceGhHostAvailable.mockResolvedValue(false); + addMarketplaceTokenAccount.mockResolvedValue({ id: "a2" }); + render(); + fireEvent.click(await screen.findByRole("radio", { name: "Access token" })); + fireEvent.change(screen.getByLabelText("Label"), { target: { value: "Gitea" } }); + fireEvent.change(screen.getByLabelText("Host"), { target: { value: "repo.anhonesthost.net" } }); + fireEvent.change(screen.getByLabelText("Token"), { target: { value: "test-token-not-real" } }); + fireEvent.click(screen.getByRole("button", { name: "Add account" })); + await waitFor(() => + expect(addMarketplaceTokenAccount).toHaveBeenCalledWith("Gitea", "repo.anhonesthost.net", "test-token-not-real"), + ); + }); + + it("shows a validation error from the backend", async () => { + marketplaceGhHostAvailable.mockResolvedValue(false); + addMarketplaceTokenAccount.mockRejectedValue("The token was rejected by repo.anhonesthost.net (HTTP 401)"); + render(); + fireEvent.click(await screen.findByRole("radio", { name: "Access token" })); + fireEvent.change(screen.getByLabelText("Label"), { target: { value: "G" } }); + fireEvent.change(screen.getByLabelText("Host"), { target: { value: "repo.anhonesthost.net" } }); + fireEvent.change(screen.getByLabelText("Token"), { target: { value: "test-token-not-real" } }); + fireEvent.click(screen.getByRole("button", { name: "Add account" })); + expect(await screen.findByText(/HTTP 401/)).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/marketplace/AddAccountModal.tsx b/app/src/components/marketplace/AddAccountModal.tsx new file mode 100644 index 0000000..efcf76e --- /dev/null +++ b/app/src/components/marketplace/AddAccountModal.tsx @@ -0,0 +1,190 @@ +import { useEffect, useState } from "react"; +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; +import SegmentedControl from "../ui/SegmentedControl"; +import Field, { inputClass, selectClass } from "../ui/Field"; +import { + addMarketplaceGhHostAccount, + addMarketplaceTokenAccount, + getSettings, + marketplaceGhHostAvailable, +} from "../../lib/tauri-commands"; +import { useAppState } from "../../store/appState"; +import GhContainerLoginModal from "./GhContainerLoginModal"; + +type Method = "gh" | "token"; + +interface Props { + onClose: () => void; +} + +export default function AddAccountModal({ onClose }: Props) { + const projects = useAppState((s) => s.projects); + const setAppSettings = useAppState((s) => s.setAppSettings); + const runnable = projects.filter((p) => p.status === "running" && p.container_id); + + const [method, setMethod] = useState("gh"); + const [hostGh, setHostGh] = useState(null); + const [label, setLabel] = useState(""); + const [host, setHost] = useState("github.com"); + const [token, setToken] = useState(""); + const [projectId, setProjectId] = useState(runnable[0]?.id ?? ""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [containerLogin, setContainerLogin] = useState(false); + + useEffect(() => { + let cancelled = false; + marketplaceGhHostAvailable() + .then((v) => { + if (!cancelled) setHostGh(v); + }) + .catch(() => { + if (!cancelled) setHostGh(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const reloadSettings = async () => setAppSettings(await getSettings()); + + const finish = async () => { + await reloadSettings(); + onClose(); + }; + + const submit = async () => { + setError(null); + if (method === "gh" && !hostGh) { + setContainerLogin(true); + return; + } + setBusy(true); + try { + if (method === "gh") { + await addMarketplaceGhHostAccount(label.trim(), host.trim()); + } else { + const t = token.trim(); + setToken(""); + await addMarketplaceTokenAccount(label.trim(), host.trim(), t); + } + await finish(); + } catch (e) { + setError(typeof e === "string" ? e : String(e)); + } finally { + setBusy(false); + } + }; + + const hostValid = /^[A-Za-z0-9.-]+(:[0-9]+)?$/.test(host.trim()); + const needsContainer = method === "gh" && hostGh === false; + const canSubmit = + !busy && + hostGh !== null && + label.trim() !== "" && + hostValid && + (method === "gh" ? !needsContainer || projectId !== "" : token.trim() !== ""); + + if (containerLogin) { + const project = runnable.find((p) => p.id === projectId); + return ( + void finish()} + /> + ); + } + + return ( + + + + + } + > +
+ + label="Sign-in method" + value={method} + onChange={(m) => { + setMethod(m); + setError(null); + }} + segments={[ + { value: "gh", label: "GitHub via gh" }, + { value: "token", label: "Access token" }, + ]} + /> + + {(id) => ( + setLabel(e.target.value)} className={inputClass} placeholder="Work GitHub" /> + )} + + + {(id) => setHost(e.target.value)} className={inputClass} />} + + {method === "gh" && hostGh === true && ( +

+ gh is installed on this computer. Triple-C asks it for a token each time it fetches, so signing out of gh + also signs this account out. If gh is not logged in yet, run gh auth login first. +

+ )} + {needsContainer && + (runnable.length === 0 ? ( +

+ gh is not installed on this computer. Start a project so gh can run in its container, or use an access token. +

+ ) : ( + + {(id) => ( + + )} + + ))} + {method === "token" && ( + + {(id) => ( + setToken(e.target.value)} + className={inputClass} + /> + )} + + )} + {error &&

{error}

} +
+
+ ); +} diff --git a/app/src/components/marketplace/GhContainerLoginModal.test.tsx b/app/src/components/marketplace/GhContainerLoginModal.test.tsx new file mode 100644 index 0000000..80cc00f --- /dev/null +++ b/app/src/components/marketplace/GhContainerLoginModal.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +const startMarketplaceGhContainerLogin = vi.fn(); +const cancelMarketplaceGhLogin = vi.fn(); +const openUrlExternal = vi.fn(); +vi.mock("../../lib/tauri-commands", () => ({ + startMarketplaceGhContainerLogin: (...a: unknown[]) => startMarketplaceGhContainerLogin(...a), + cancelMarketplaceGhLogin: () => cancelMarketplaceGhLogin(), + openUrlExternal: (u: string) => openUrlExternal(u), +})); + +const handlers = new Map void>(); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async (name: string, cb: (e: { payload: unknown }) => void) => { + handlers.set(name, cb); + return vi.fn(); + }), +})); + +import GhContainerLoginModal from "./GhContainerLoginModal"; + +describe("GhContainerLoginModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + handlers.clear(); + }); + + it("shows the device code, opens the URL, and finishes", async () => { + let resolve!: (v: unknown) => void; + startMarketplaceGhContainerLogin.mockReturnValue(new Promise((r) => (resolve = r))); + const onDone = vi.fn(); + render( + , + ); + await waitFor(() => expect(handlers.has("marketplace-gh-login-code")).toBe(true)); + await waitFor(() => expect(startMarketplaceGhContainerLogin).toHaveBeenCalledWith("Work", "github.com", "p1")); + + act(() => + handlers.get("marketplace-gh-login-code")!({ + payload: { account_id: "unknown-yet", code: "ABCD-1234", url: "https://github.com/login/device" }, + }), + ); + expect(screen.getByText("ABCD-1234")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Open GitHub" })); + expect(openUrlExternal).toHaveBeenCalledWith("https://github.com/login/device"); + + await act(async () => resolve({ id: "acc9", label: "Work", host: "github.com", method: "gh_container", username: "me" })); + await waitFor(() => expect(onDone).toHaveBeenCalled()); + }); + + it("refuses to open a non-GitHub URL from the container", async () => { + startMarketplaceGhContainerLogin.mockReturnValue(new Promise(() => {})); + render(); + await waitFor(() => expect(handlers.has("marketplace-gh-login-code")).toBe(true)); + act(() => + handlers.get("marketplace-gh-login-code")!({ + payload: { account_id: "x", code: "ABCD-1234", url: "https://evil.example/login" }, + }), + ); + expect(screen.queryByRole("button", { name: "Open GitHub" })).not.toBeInTheDocument(); + }); + + it("cancels", async () => { + startMarketplaceGhContainerLogin.mockReturnValue(new Promise(() => {})); + const onClose = vi.fn(); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Cancel sign-in" })); + expect(cancelMarketplaceGhLogin).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/marketplace/GhContainerLoginModal.tsx b/app/src/components/marketplace/GhContainerLoginModal.tsx new file mode 100644 index 0000000..33f9bad --- /dev/null +++ b/app/src/components/marketplace/GhContainerLoginModal.tsx @@ -0,0 +1,158 @@ +import { useEffect, useRef, useState } from "react"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; +import StatusIndicator from "../ui/StatusIndicator"; +import { + cancelMarketplaceGhLogin, + openUrlExternal, + startMarketplaceGhContainerLogin, +} from "../../lib/tauri-commands"; +import type { MarketplaceAccount } from "../../lib/types"; + +interface Props { + label: string; + host: string; + projectId: string; + projectName: string; + onClose: () => void; + onDone: (account: MarketplaceAccount) => void; +} + +interface CodeEvent { + account_id: string; + code: string; + url: string; +} +interface OutputEvent { + account_id: string; + chunk: string; +} + +const MAX_OUTPUT = 8000; + +/** Only open device-login pages on the host being signed in to. */ +function safeDeviceUrl(url: string, host: string): string | null { + try { + const u = new URL(url); + return u.protocol === "https:" && u.hostname === host ? u.toString() : null; + } catch { + return null; + } +} + +/** + * Drives `gh auth login --web` inside a running container. The command only + * resolves when the login finishes, so the new account's id is unknown while it + * runs; the modal accepts every gh-login event while open. The backend allows + * one gh login at a time, so there is never another flow's event to confuse. + */ +export default function GhContainerLoginModal({ label, host, projectId, projectName, onClose, onDone }: Props) { + const [code, setCode] = useState(null); + const [url, setUrl] = useState(null); + const [output, setOutput] = useState(""); + const [error, setError] = useState(null); + const [running, setRunning] = useState(true); + const started = useRef(false); + + useEffect(() => { + let cancelled = false; + const unlisteners: UnlistenFn[] = []; + const register = async (name: string, handle: (p: T) => void) => { + const un = await listen(name, (e) => handle(e.payload)); + if (cancelled) un(); + else unlisteners.push(un); + }; + + void (async () => { + await register("marketplace-gh-login-code", (p) => { + setCode(p.code); + setUrl(p.url); + }); + await register("marketplace-gh-login-output", (p) => + setOutput((prev) => { + const next = prev + p.chunk; + return next.length > MAX_OUTPUT ? next.slice(next.length - MAX_OUTPUT) : next; + }), + ); + if (cancelled || started.current) return; + started.current = true; + try { + const account = await startMarketplaceGhContainerLogin(label, host, projectId); + if (!cancelled) { + setRunning(false); + onDone(account); + } + } catch (e) { + if (!cancelled) { + setRunning(false); + setError(typeof e === "string" ? e : String(e)); + } + } + })(); + + return () => { + cancelled = true; + for (const un of unlisteners) { + try { + un(); + } catch { + /* already gone */ + } + } + }; + // Runs once per modal instance; the props do not change while it is open. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const cancel = () => { + void cancelMarketplaceGhLogin(); + onClose(); + }; + + const openable = url ? safeDeviceUrl(url, host) : null; + + return ( + + Cancel sign-in + + ) : ( + + ) + } + > +
+ {running && !code && } + {code && running && ( +
+

Enter this code on the GitHub device page:

+

{code}

+ {openable ? ( + + ) : ( + url &&

The sign-in URL did not point at {host}; not opening it.

+ )} +
+ )} + {error &&

{error}

} + {output && ( +
+            {output}
+          
+ )} +
+
+ ); +}