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 { 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 }) {
|
||||
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 (
|
||||
<p className="p-4 text-xs text-[var(--text-secondary)]">
|
||||
{count} account{count === 1 ? "" : "s"}.
|
||||
</p>
|
||||
<div className="p-4 space-y-3 max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<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