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.
+
+
setAdding(true)}>
+ Add account
+
+
+ {accounts.length === 0 &&
No accounts yet. Public repositories need none.
}
+
+ {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 (
+
+
+ Cancel
+
+ void submit()} disabled={!canSubmit}>
+ {needsContainer ? "Sign in" : busy ? "Checking…" : "Add account"}
+
+ >
+ }
+ >
+
+
+ );
+}
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
+
+ ) : (
+
+ Close
+
+ )
+ }
+ >
+
+ {running && !code &&
}
+ {code && running && (
+
+
Enter this code on the GitHub device page:
+
{code}
+ {openable ? (
+
void openUrlExternal(openable)}>
+ Open GitHub
+
+ ) : (
+ url &&
The sign-in URL did not point at {host}; not opening it.
+ )}
+
+ )}
+ {error &&
{error}
}
+ {output && (
+
+ {output}
+
+ )}
+
+
+ );
+}