Marketplace UI: accounts — gh on host, gh in a container, access tokens
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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: () => <div>add account modal</div> }));
|
||||||
|
|
||||||
|
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(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||||
|
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(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||||
|
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(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||||
|
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(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||||
|
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(<AccountsPane mp={{} as MarketplaceApi} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add account" }));
|
||||||
|
expect(screen.getByText("add account modal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,108 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||||
import { useAppState } from "../../store/appState";
|
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<AccountMethod, string> = {
|
||||||
|
gh_host: "GitHub — gh on this computer",
|
||||||
|
gh_container: "GitHub — signed in via container",
|
||||||
|
token: "Access token",
|
||||||
|
};
|
||||||
|
|
||||||
export default function AccountsPane(_props: { mp: MarketplaceApi }) {
|
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<string | null>(null);
|
||||||
|
const [removing, setRemoving] = useState<string | null>(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 (
|
return (
|
||||||
<p className="p-4 text-xs text-[var(--text-secondary)]">
|
<div className="p-4 space-y-3 max-w-3xl">
|
||||||
{count} account{count === 1 ? "" : "s"}.
|
<div className="flex items-center justify-between">
|
||||||
</p>
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Accounts are used to fetch private marketplaces. Tokens are kept in your OS keychain and never enter
|
||||||
|
containers.
|
||||||
|
</p>
|
||||||
|
<Button size="md" variant="secondary" onClick={() => setAdding(true)}>
|
||||||
|
Add account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{accounts.length === 0 && <p className="text-xs text-[var(--text-secondary)]">No accounts yet. Public repositories need none.</p>}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{accounts.map((a) => {
|
||||||
|
const users = usedBy(a.id);
|
||||||
|
const inUse = users.length > 0;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="flex items-center justify-between gap-2 p-2 rounded-[var(--radius-control)] border border-[var(--border-color)]"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 text-xs">
|
||||||
|
<p className="font-medium">{a.label}</p>
|
||||||
|
<p className="text-[var(--text-secondary)]">
|
||||||
|
{METHOD_LABEL[a.method]} · {a.host}
|
||||||
|
{a.username ? ` · ${a.username}` : ""}
|
||||||
|
</p>
|
||||||
|
{inUse && <p className="text-[var(--text-secondary)]">Used by {users.join(", ")}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Test ${a.label}`}
|
||||||
|
disabled={testing === a.id}
|
||||||
|
onClick={() => void test(a)}
|
||||||
|
>
|
||||||
|
{testing === a.id ? "Testing…" : "Test"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Remove ${a.label}`}
|
||||||
|
disabled={removing === a.id}
|
||||||
|
unavailable={inUse}
|
||||||
|
unavailableReason={`Used by ${users.join(", ")} — change or remove that marketplace first`}
|
||||||
|
onClick={() => void remove(a)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{adding && <AddAccountModal onClose={() => setAdding(false)} />}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) => <div>container login for {projectId}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(<AddAccountModal onClose={onClose} />);
|
||||||
|
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(<AddAccountModal onClose={vi.fn()} />);
|
||||||
|
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(<AddAccountModal onClose={vi.fn()} />);
|
||||||
|
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(<AddAccountModal onClose={vi.fn()} />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Method>("gh");
|
||||||
|
const [hostGh, setHostGh] = useState<boolean | null>(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<string | null>(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 (
|
||||||
|
<GhContainerLoginModal
|
||||||
|
label={label.trim()}
|
||||||
|
host={host.trim()}
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={project?.name ?? projectId}
|
||||||
|
onClose={onClose}
|
||||||
|
onDone={() => void finish()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Add account"
|
||||||
|
description="Accounts let Triple-C read private marketplace repositories. Credentials stay on this computer and never enter containers."
|
||||||
|
widthClassName="w-[36rem]"
|
||||||
|
dismissible={!busy}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="primary" onClick={() => void submit()} disabled={!canSubmit}>
|
||||||
|
{needsContainer ? "Sign in" : busy ? "Checking…" : "Add account"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SegmentedControl<Method>
|
||||||
|
label="Sign-in method"
|
||||||
|
value={method}
|
||||||
|
onChange={(m) => {
|
||||||
|
setMethod(m);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
segments={[
|
||||||
|
{ value: "gh", label: "GitHub via gh" },
|
||||||
|
{ value: "token", label: "Access token" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Field label="Label">
|
||||||
|
{(id) => (
|
||||||
|
<input id={id} value={label} onChange={(e) => setLabel(e.target.value)} className={inputClass} placeholder="Work GitHub" />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Host" hint={hostValid ? undefined : "Host name only, e.g. github.com or repo.example.com"}>
|
||||||
|
{(id) => <input id={id} value={host} onChange={(e) => setHost(e.target.value)} className={inputClass} />}
|
||||||
|
</Field>
|
||||||
|
{method === "gh" && hostGh === true && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
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 <code className="font-mono">gh auth login</code> first.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{needsContainer &&
|
||||||
|
(runnable.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--warning)]">
|
||||||
|
gh is not installed on this computer. Start a project so gh can run in its container, or use an access token.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Field
|
||||||
|
label="Run gh in"
|
||||||
|
hint="gh is not installed on this computer, so the sign-in runs in this container. The token is kept in your OS keychain, not in the container."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<select id={id} value={projectId} onChange={(e) => setProjectId(e.target.value)} className={selectClass}>
|
||||||
|
{runnable.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
{method === "token" && (
|
||||||
|
<Field
|
||||||
|
label="Token"
|
||||||
|
hint="A personal access token with read access to the repository. For GitHub SSO orgs, authorise the token for the org."
|
||||||
|
>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{error && <p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, (e: { payload: unknown }) => 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(
|
||||||
|
<GhContainerLoginModal label="Work" host="github.com" projectId="p1" projectName="api" onClose={vi.fn()} onDone={onDone} />,
|
||||||
|
);
|
||||||
|
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(<GhContainerLoginModal label="W" host="github.com" projectId="p1" projectName="api" onClose={vi.fn()} onDone={vi.fn()} />);
|
||||||
|
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(<GhContainerLoginModal label="W" host="github.com" projectId="p1" projectName="api" onClose={onClose} onDone={vi.fn()} />);
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Cancel sign-in" }));
|
||||||
|
expect(cancelMarketplaceGhLogin).toHaveBeenCalled();
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [running, setRunning] = useState(true);
|
||||||
|
const started = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const unlisteners: UnlistenFn[] = [];
|
||||||
|
const register = async <T,>(name: string, handle: (p: T) => void) => {
|
||||||
|
const un = await listen<T>(name, (e) => handle(e.payload));
|
||||||
|
if (cancelled) un();
|
||||||
|
else unlisteners.push(un);
|
||||||
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await register<CodeEvent>("marketplace-gh-login-code", (p) => {
|
||||||
|
setCode(p.code);
|
||||||
|
setUrl(p.url);
|
||||||
|
});
|
||||||
|
await register<OutputEvent>("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 (
|
||||||
|
<Modal
|
||||||
|
title={`Sign in to ${host} with gh`}
|
||||||
|
description={`Running gh auth login in "${projectName}". The sign-in is not kept in that container.`}
|
||||||
|
widthClassName="w-[40rem]"
|
||||||
|
dismissible={!running}
|
||||||
|
onClose={running ? cancel : onClose}
|
||||||
|
footer={
|
||||||
|
running ? (
|
||||||
|
<Button size="md" variant="ghost" onClick={cancel}>
|
||||||
|
Cancel sign-in
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="md" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{running && !code && <StatusIndicator tone="busy" label="Starting gh…" className="text-xs" />}
|
||||||
|
{code && running && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs">Enter this code on the GitHub device page:</p>
|
||||||
|
<p className="font-mono text-lg tracking-widest select-all">{code}</p>
|
||||||
|
{openable ? (
|
||||||
|
<Button size="md" variant="primary" onClick={() => void openUrlExternal(openable)}>
|
||||||
|
Open GitHub
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
url && <p className="text-xs text-[var(--error)]">The sign-in URL did not point at {host}; not opening it.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p role="alert" className="text-xs text-[var(--error)] whitespace-pre-wrap">{error}</p>}
|
||||||
|
{output && (
|
||||||
|
<pre className="max-h-40 overflow-auto p-2 text-[11px] font-mono whitespace-pre-wrap rounded-[var(--radius-control)] bg-[var(--bg-primary)] border border-[var(--border-color)]">
|
||||||
|
{output}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user