fabro/apps/fabro-web/app/install-app.tsx
Bryan Helmkamp 7ed57ba863
fix(install): show loading until session resolves, not just during fetch
GithubAppDoneScreen was firing `<Navigate to="/install/github">` on the
first render after GitHub's manifest callback because the render path
used `sessionState.status === "loading"` as its loading gate. Between
initial mount (sessionState defaults to "idle") and the session-fetch
useEffect flipping it to "loading", the main layout rendered once with
`session === null`. Done screen saw `github === undefined`, treated the
session as misconfigured, and bounced the user back to the already-done
"Connect GitHub" form — a redirect loop after a successful GitHub App
install. Broaden the gate to `!session` so every transient state with a
token-but-no-session shows the loading screen, not a half-rendered step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:40:21 -04:00

1471 lines
47 KiB
TypeScript

import { startTransition, useEffect, useMemo, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import { Link, Navigate, useLocation, useNavigate } from "react-router";
import {
ArrowLeftIcon,
ArrowRightIcon,
ArrowTopRightOnSquareIcon,
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
ClipboardDocumentCheckIcon,
ClipboardIcon,
EyeIcon,
EyeSlashIcon,
} from "@heroicons/react/16/solid";
import {
type InstallFinishResponse,
type InstallGithubAppOwner,
type InstallLlmProviderInput,
type InstallSessionResponse,
createInstallGithubAppManifest,
finishInstall,
getInstallSession,
persistInstallToken,
putInstallGithubToken,
putInstallLlm,
putInstallServer,
readStoredInstallToken,
testInstallGithubToken,
testInstallLlm,
} from "./install-api";
import { INSTALL_PROVIDERS } from "./install-config";
import { shouldRedirectAfterHealthPoll } from "./install-flow";
import {
consumeInstallGithubErrorFromUrl,
consumeInstallTokenFromUrl,
shouldConsumeInstallGithubErrorForPath,
} from "./mode";
import {
CopyButton,
ErrorMessage,
INPUT_CLASS,
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
} from "./components/ui";
import { LoadingState } from "./components/state";
const INSTALL_STEPS = [
{ id: "welcome", label: "Welcome", href: "/install/welcome" },
{ id: "server", label: "Server", href: "/install/server" },
{ id: "llm", label: "LLMs", href: "/install/llm" },
{ id: "github", label: "GitHub", href: "/install/github" },
{ id: "review", label: "Review", href: "/install/review" },
] as const;
const STEPPER_STEPS = INSTALL_STEPS.slice(1);
type StepId = (typeof INSTALL_STEPS)[number]["id"];
type FinishState = InstallFinishResponse | null;
type GithubStrategy = "token" | "app";
type GithubOwnerKind = "personal" | "org";
type SessionState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready"; data: InstallSessionResponse };
type TokenForm = { token: string; username: string };
type AppForm = {
owner: InstallGithubAppOwner;
appName: string;
allowedUsername: string;
};
type ProviderSelection = Record<string, { apiKey: string }>;
export default function InstallApp() {
const navigate = useNavigate();
const location = useLocation();
const [installToken, setInstallToken] = useState<string | null>(() =>
readStoredInstallToken(),
);
const [sessionState, setSessionState] = useState<SessionState>({ status: "idle" });
const session = sessionState.status === "ready" ? sessionState.data : null;
const [manualToken, setManualToken] = useState("");
const [llmSelection, setLlmSelection] = useState<ProviderSelection>(() =>
defaultProviderSelection(),
);
const [canonicalUrl, setCanonicalUrl] = useState("");
const [githubStrategy, setGithubStrategy] = useState<GithubStrategy>("token");
const [tokenForm, setTokenForm] = useState<TokenForm>({ token: "", username: "" });
const [appForm, setAppForm] = useState<AppForm>({
owner: { kind: "personal" },
appName: "Fabro",
allowedUsername: "",
});
const [saveError, setSaveError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [finishState, setFinishState] = useState<FinishState>(null);
const [timedOut, setTimedOut] = useState(false);
useEffect(() => {
const { token, sanitizedUrl } = consumeInstallTokenFromUrl(window.location.href);
if (!token) return;
persistInstallToken(token);
setInstallToken(token);
window.history.replaceState(window.history.state, "", sanitizedUrl);
}, []);
useEffect(() => {
if (shouldConsumeInstallGithubErrorForPath(location.pathname)) {
const { error, sanitizedUrl } = consumeInstallGithubErrorFromUrl(window.location.href);
if (error) {
setSaveError(error);
window.history.replaceState(window.history.state, "", sanitizedUrl);
return;
}
}
setSaveError((current) => (current === null ? current : null));
}, [location.pathname]);
useEffect(() => {
if (!installToken) {
setSessionState({ status: "idle" });
return;
}
let cancelled = false;
setSessionState({ status: "loading" });
getInstallSession(installToken)
.then((nextSession) => {
if (cancelled) return;
setSessionState({ status: "ready", data: nextSession });
setCanonicalUrl((current) =>
current || nextSession.server?.canonical_url || nextSession.prefill.canonical_url,
);
setLlmSelection((current) =>
hydrateProviderSelection(current, nextSession),
);
if (nextSession.github?.strategy === "app") {
setGithubStrategy("app");
setAppForm({
owner: nextSession.github.owner ?? { kind: "personal" },
appName: nextSession.github.app_name || "Fabro",
allowedUsername: nextSession.github.allowed_username || "",
});
} else if (nextSession.github?.strategy === "token") {
setGithubStrategy("token");
setTokenForm((current) => ({
...current,
username: nextSession.github?.username || current.username,
}));
}
})
.catch((error) => {
if (cancelled) return;
setSessionState({
status: "error",
message: error instanceof Error ? error.message : "Install session failed",
});
});
return () => {
cancelled = true;
};
}, [installToken]);
useEffect(() => {
if (!installToken || !session) return;
if ((location.pathname === "/" || location.pathname === "/install") && !finishState) {
startTransition(() => {
navigate("/install/welcome", { replace: true });
});
}
}, [finishState, installToken, location.pathname, navigate, session]);
useEffect(() => {
if (!finishState) return;
setTimedOut(false);
const deadline = window.setTimeout(() => {
setTimedOut(true);
}, 30_000);
const controller = new AbortController();
let inFlight = false;
const poll = async () => {
if (inFlight || controller.signal.aborted) return;
inFlight = true;
try {
const response = await fetch("/health", { signal: controller.signal });
const body = response.ok
? ((await response.json()) as { mode?: string })
: undefined;
if (
shouldRedirectAfterHealthPoll({
kind: "response",
ok: response.ok,
mode: body?.mode,
})
) {
window.location.href = finishState.restart_url;
}
} catch {
if (controller.signal.aborted) return;
if (shouldRedirectAfterHealthPoll({ kind: "error" })) {
window.location.href = finishState.restart_url;
}
} finally {
inFlight = false;
}
};
const interval = window.setInterval(poll, 2_000);
return () => {
controller.abort();
window.clearTimeout(deadline);
window.clearInterval(interval);
};
}, [finishState]);
const currentStep = useMemo<StepId>(() => {
if (location.pathname.startsWith("/install/llm")) return "llm";
if (location.pathname.startsWith("/install/server")) return "server";
if (location.pathname.startsWith("/install/github")) return "github";
if (location.pathname.startsWith("/install/review")) return "review";
return "welcome";
}, [location.pathname]);
const completedSteps = new Set(session?.completed_steps ?? []);
const sessionError =
sessionState.status === "error" ? sessionState.message : null;
if (!installToken) {
return (
<TokenEntryScreen
manualToken={manualToken}
setManualToken={setManualToken}
sessionError={sessionError}
onSubmit={() => {
const nextToken = manualToken.trim();
if (!nextToken) {
setSessionState({
status: "error",
message: "Paste the install token from the server logs.",
});
return;
}
persistInstallToken(nextToken);
setInstallToken(nextToken);
setSessionState({ status: "idle" });
}}
/>
);
}
if (sessionState.status === "error") {
return (
<TokenEntryScreen
manualToken={manualToken}
setManualToken={setManualToken}
sessionError={sessionError}
onSubmit={() => {
const nextToken = manualToken.trim();
persistInstallToken(nextToken);
setInstallToken(nextToken || null);
}}
/>
);
}
// Covers both sessionState "loading" AND the brief "idle" window between
// the initial render and the session-fetch useEffect. Without this guard,
// screens like GithubAppDoneScreen see `session == null` and navigate away
// before the first fetch finishes — trapping the user in a redirect loop.
if (!session) {
return (
<InstallLayout currentStep={currentStep} completedSteps={completedSteps}>
<LoadingState label="Connecting to install session…" />
</InstallLayout>
);
}
if (finishState && location.pathname !== "/install/finishing") {
return <Navigate to="/install/finishing" replace />;
}
return (
<InstallLayout currentStep={currentStep} completedSteps={completedSteps}>
{location.pathname === "/install/finishing" ? (
<FinishingScreen finishState={finishState} timedOut={timedOut} />
) : location.pathname === "/install/llm" ? (
<StepPanel
title="Add your LLM credentials"
description="Each key you enter is validated before it's saved. Skip a provider by leaving it blank."
error={saveError}
submitting={submitting}
backHref="/install/server"
onSubmit={async () => {
const providers = INSTALL_PROVIDERS.map(({ id }) => {
const current = llmSelection[id] ?? { apiKey: "" };
return {
provider: id,
api_key: current.apiKey.trim(),
};
}).filter((provider) => provider.api_key.length > 0);
if (providers.length === 0) {
setSaveError("Add at least one provider API key before continuing.");
return;
}
setSubmitting(true);
setSaveError(null);
try {
await Promise.all(
providers.map((provider) => testInstallLlm(installToken, provider)),
);
await putInstallLlm(installToken, providers);
const nextSession = await getInstallSession(installToken);
setSessionState({ status: "ready", data: nextSession });
navigate("/install/github");
} catch (error) {
setSaveError(
error instanceof Error ? error.message : "Failed to save LLM settings.",
);
} finally {
setSubmitting(false);
}
}}
>
<ProviderFields value={llmSelection} onChange={setLlmSelection} />
</StepPanel>
) : location.pathname === "/install/server" ? (
<StepPanel
title="Confirm the public URL"
description="This is where operators will reach Fabro after setup. It's also the redirect target for the GitHub App callback."
error={saveError}
submitting={submitting}
backHref="/install/welcome"
onSubmit={async () => {
if (!canonicalUrl.trim()) {
setSaveError("Enter the canonical server URL before continuing.");
return;
}
setSubmitting(true);
setSaveError(null);
try {
await putInstallServer(installToken, canonicalUrl.trim());
const nextSession = await getInstallSession(installToken);
setSessionState({ status: "ready", data: nextSession });
navigate("/install/llm");
} catch (error) {
setSaveError(
error instanceof Error ? error.message : "Failed to save server settings.",
);
} finally {
setSubmitting(false);
}
}}
>
<Field
label="Canonical URL"
hint="Auto-detected from forwarded headers when available."
>
<input
type="url"
name="canonical_url"
value={canonicalUrl}
onChange={(event) => setCanonicalUrl(event.target.value)}
className={INPUT_CLASS}
placeholder="https://fabro.example.com"
autoComplete="url"
spellCheck={false}
/>
</Field>
</StepPanel>
) : location.pathname === "/install/github/done" ? (
<GithubAppDoneScreen github={session?.github} />
) : location.pathname === "/install/github" ? (
<StepPanel
title="Connect GitHub"
description="Choose how Fabro should authenticate. Tokens are stored in the vault; apps hand off to GitHub and return here."
error={saveError}
submitting={submitting}
submitLabel={githubStrategy === "app" ? "Continue on GitHub" : "Continue"}
backHref="/install/server"
onSubmit={async () => {
setSubmitting(true);
setSaveError(null);
try {
if (githubStrategy === "token") {
const trimmedToken = tokenForm.token.trim();
if (!trimmedToken) {
setSaveError("Provide the GitHub token before continuing.");
return;
}
const username = await testInstallGithubToken(installToken, trimmedToken);
setTokenForm({ token: trimmedToken, username });
await putInstallGithubToken(installToken, trimmedToken, username);
const nextSession = await getInstallSession(installToken);
setSessionState({ status: "ready", data: nextSession });
navigate("/install/review");
return;
}
const { owner, appName, allowedUsername } = appForm;
if (owner.kind === "org" && !(owner.slug ?? "").trim()) {
setSaveError("Enter the organization slug for the GitHub App.");
return;
}
if (!appName.trim()) {
setSaveError("Enter the GitHub App name before continuing.");
return;
}
if (!allowedUsername.trim()) {
setSaveError("Enter the GitHub username that should be allowed to log in.");
return;
}
const manifest = await createInstallGithubAppManifest(installToken, {
owner:
owner.kind === "org"
? { kind: "org", slug: (owner.slug ?? "").trim() }
: { kind: "personal" },
app_name: appName.trim(),
allowed_username: allowedUsername.trim(),
});
submitGithubManifest(
manifest.github_form_action,
manifest.manifest,
manifest.state,
);
} catch (error) {
setSaveError(
error instanceof Error ? error.message : "Failed to start GitHub setup.",
);
} finally {
setSubmitting(false);
}
}}
>
<GithubStrategyPicker strategy={githubStrategy} onChange={setGithubStrategy} />
{githubStrategy === "token" ? (
<div className="space-y-5">
<div>
<label
htmlFor="github_token"
className="text-sm font-medium text-fg"
>
Personal access token
</label>
<div className="mt-2">
<PasswordInput
id="github_token"
name="github_token"
value={tokenForm.token}
onChange={(value) =>
setTokenForm((current) => ({ ...current, token: value }))
}
placeholder="ghp_..."
/>
</div>
{tokenForm.username ? (
<p className="mt-2 inline-flex items-center gap-1.5 text-xs text-mint">
<CheckCircleIcon className="size-4 shrink-0" />
Previously validated as{" "}
<span className="font-medium">@{tokenForm.username}</span>
</p>
) : null}
<HelpDisclosure summary="Where do I get this?">
<p>
Create a fine-grained or classic token with{" "}
<code className="font-mono text-fg-2">repo</code> scope.
</p>
<ExternalLink href="https://github.com/settings/tokens">
github.com/settings/tokens
</ExternalLink>
</HelpDisclosure>
</div>
</div>
) : (
<div className="space-y-5">
<OwnerPicker
ownerKind={appForm.owner.kind}
setOwnerKind={(kind) =>
setAppForm((current) => ({
...current,
owner:
kind === "org"
? { kind: "org", slug: current.owner.kind === "org" ? current.owner.slug ?? "" : "" }
: { kind: "personal" },
}))
}
/>
{appForm.owner.kind === "org" ? (
<Field label="Organization slug">
<input
name="github_org_slug"
value={appForm.owner.slug ?? ""}
onChange={(event) =>
setAppForm((current) => ({
...current,
owner: { kind: "org", slug: event.target.value },
}))
}
className={INPUT_CLASS}
placeholder="acme"
spellCheck={false}
/>
</Field>
) : null}
<Field
label="Allowed GitHub username"
hint="Only this username can log in through GitHub after setup."
>
<input
name="github_allowed_username"
value={appForm.allowedUsername}
onChange={(event) =>
setAppForm((current) => ({
...current,
allowedUsername: event.target.value,
}))
}
className={INPUT_CLASS}
placeholder="octocat"
spellCheck={false}
/>
</Field>
{session?.server?.canonical_url ? (
<p className="text-xs text-fg-muted">
After creating the app, GitHub will redirect back to{" "}
<code className="font-mono text-fg-3">{session.server.canonical_url}</code>.
</p>
) : null}
</div>
)}
</StepPanel>
) : location.pathname === "/install/review" ? (
<ReviewScreen
session={session}
error={saveError}
submitting={submitting}
onInstall={async () => {
setSubmitting(true);
setSaveError(null);
try {
const result = await finishInstall(installToken);
setFinishState(result);
navigate("/install/finishing");
} catch (error) {
setSaveError(error instanceof Error ? error.message : "Install failed.");
} finally {
setSubmitting(false);
}
}}
/>
) : (
<WelcomeScreen />
)}
</InstallLayout>
);
}
function TokenEntryScreen({
manualToken,
setManualToken,
sessionError,
onSubmit,
}: {
manualToken: string;
setManualToken: (value: string) => void;
sessionError: string | null;
onSubmit: () => void;
}) {
return (
<main className="min-h-dvh bg-atmosphere px-4 py-16 text-fg-2 antialiased sm:py-20">
<div className="relative mx-auto max-w-md">
<div className="flex items-center gap-3">
<img src="/logo.svg" alt="Fabro" className="size-8" draggable={false} />
<span className="text-sm font-medium text-fg-3">Install</span>
</div>
<div className="mt-10">
<h1 className="text-2xl font-semibold tracking-tight text-fg sm:text-[1.75rem]">
Finish configuring this Fabro server
</h1>
<p className="mt-3 max-w-[56ch] text-sm/6 text-fg-3 text-pretty">
Find the one-time install token in your terminal, Docker logs, or
platform log viewer, then paste it here to continue.
</p>
</div>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
className="mt-8 space-y-5"
>
<div>
<label htmlFor="install-token" className="sr-only">
Install token
</label>
<textarea
id="install-token"
name="install_token"
value={manualToken}
onChange={(event) => setManualToken(event.target.value)}
className={`${INPUT_CLASS} min-h-28 resize-y font-mono`}
placeholder="Paste install token"
spellCheck={false}
autoFocus
/>
</div>
{sessionError ? <ErrorMessage message={sessionError} /> : null}
<button type="submit" className={PRIMARY_BUTTON_CLASS}>
Continue
<ArrowRightIcon className="size-4 shrink-0" />
</button>
</form>
<section className="mt-10 border-t border-line pt-6">
<h2 className="text-xs font-semibold tracking-wide text-fg uppercase">
Where to find it
</h2>
<dl className="mt-4 space-y-2 text-sm/6 text-fg-3">
<div className="flex gap-3">
<dt className="w-24 shrink-0 text-fg-2">Local</dt>
<dd>
Output of{" "}
<code className="font-mono text-fg-2">fabro server start</code>
</dd>
</div>
<div className="flex gap-3">
<dt className="w-24 shrink-0 text-fg-2">Docker</dt>
<dd>
<code className="font-mono text-fg-2">docker logs &lt;container&gt;</code>
</dd>
</div>
<div className="flex gap-3">
<dt className="w-24 shrink-0 text-fg-2">Hosted</dt>
<dd>Your platform's log viewer or <code className="font-mono text-fg-2">journalctl</code></dd>
</div>
</dl>
</section>
<p className="mt-10 text-xs text-fg-muted">
Install mode is temporary and only available until setup completes.
</p>
</div>
</main>
);
}
function InstallLayout({
children,
currentStep,
completedSteps,
}: {
children: ReactNode;
currentStep: StepId;
completedSteps: Set<string>;
}) {
const showStepper = currentStep !== "welcome";
return (
<main className="min-h-dvh bg-atmosphere px-4 py-12 text-fg-2 antialiased sm:py-16">
<div className="relative mx-auto max-w-xl">
<div className="flex items-center gap-3">
<img src="/logo.svg" alt="Fabro" className="size-8" draggable={false} />
<span className="text-sm font-medium text-fg-3">Install</span>
</div>
{showStepper ? (
<div className="mt-8">
<Stepper currentStep={currentStep} completedSteps={completedSteps} />
</div>
) : null}
<div className="mt-10 sm:mt-12">{children}</div>
</div>
</main>
);
}
function Stepper({
currentStep,
completedSteps,
}: {
currentStep: StepId;
completedSteps: Set<string>;
}) {
const activeIndex = STEPPER_STEPS.findIndex((step) => step.id === currentStep);
const safeIndex = activeIndex === -1 ? 0 : activeIndex;
const activeStep = STEPPER_STEPS[safeIndex];
const progress = ((safeIndex + 1) / STEPPER_STEPS.length) * 100;
return (
<nav aria-label="Install progress">
<div className="sm:hidden">
<p className="text-xs font-medium text-fg-3 tabular-nums">
Step {safeIndex + 1} of {STEPPER_STEPS.length}
<span className="text-fg"> · {activeStep.label}</span>
</p>
<div className="mt-2 h-1 overflow-hidden rounded-full bg-overlay">
<div
className="h-full rounded-full bg-teal-500 transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<ol role="list" className="hidden items-center sm:flex">
{STEPPER_STEPS.map((step, index) => {
const isComplete = completedSteps.has(step.id);
const isCurrent = step.id === currentStep;
const isLast = index === STEPPER_STEPS.length - 1;
const isLinkable = isComplete || isCurrent;
const circleClass = isComplete
? "bg-mint text-on-primary"
: isCurrent
? "bg-teal-500 text-on-primary"
: "bg-overlay text-fg-muted outline-1 -outline-offset-1 outline-white/10";
const labelClass = isCurrent
? "text-fg"
: isComplete
? "text-fg-2"
: "text-fg-muted";
const connectorClass = isComplete ? "bg-mint/40" : "bg-line-strong";
const inner = (
<>
<span
className={`flex size-6 items-center justify-center rounded-full text-xs font-semibold tabular-nums ${circleClass}`}
aria-hidden="true"
>
{isComplete ? <CheckIcon className="size-3.5" /> : index + 1}
</span>
<span className={`text-xs font-medium ${labelClass}`}>
{step.label}
</span>
</>
);
return (
<li
key={step.id}
className={`flex items-center ${isLast ? "" : "flex-1"}`}
>
{isLinkable ? (
<Link
to={step.href}
aria-current={isCurrent ? "step" : undefined}
className="flex items-center gap-2 rounded-md outline-teal-500 focus-visible:outline-2 focus-visible:outline-offset-4"
>
{inner}
</Link>
) : (
<span className="flex items-center gap-2" aria-disabled="true">
{inner}
</span>
)}
{isLast ? null : (
<span
aria-hidden="true"
className={`mx-3 h-px flex-1 ${connectorClass}`}
/>
)}
</li>
);
})}
</ol>
</nav>
);
}
function WelcomeScreen() {
return (
<div>
<h1 className="text-3xl font-semibold tracking-tight text-fg text-balance sm:text-4xl">
Set up your Fabro server
</h1>
<p className="mt-4 max-w-[56ch] text-base/7 text-fg-3 text-pretty sm:text-[0.9375rem]/7">
A short walkthrough to validate your LLM credentials, confirm the public
server URL, and connect GitHub. When you finish, Fabro restarts into
normal mode.
</p>
<ol role="list" className="mt-10 divide-y divide-line border-y border-line">
{[
["Server URL", "Confirm where operators will reach Fabro."],
["LLMs", "Validate API keys for Anthropic, OpenAI, or Gemini."],
["GitHub", "Choose a personal access token or a GitHub App."],
["Review", "Double-check the plan, then write the files."],
].map(([title, body], index) => (
<li key={title} className="flex items-start gap-4 py-4">
<span
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-overlay text-xs font-semibold tabular-nums text-fg-2 outline-1 -outline-offset-1 outline-white/10"
aria-hidden="true"
>
{index + 1}
</span>
<div>
<p className="text-sm font-medium text-fg">{title}</p>
<p className="mt-1 text-sm/6 text-fg-3">{body}</p>
</div>
</li>
))}
</ol>
<div className="mt-10 flex justify-end">
<Link to="/install/server" className={PRIMARY_BUTTON_CLASS}>
Start setup
<ArrowRightIcon className="size-4 shrink-0" />
</Link>
</div>
</div>
);
}
function StepPanel({
title,
description,
children,
error,
submitting,
submitLabel = "Continue",
backHref,
onSubmit,
}: {
title: string;
description: string;
children: ReactNode;
error: string | null;
submitting: boolean;
submitLabel?: string;
backHref?: string;
onSubmit: () => Promise<void>;
}) {
return (
<form
onSubmit={(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (submitting) return;
void onSubmit();
}}
className="space-y-8"
>
<header>
<h1 className="text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
{title}
</h1>
<p className="mt-3 max-w-[56ch] text-sm/6 text-fg-3 text-pretty">
{description}
</p>
</header>
<div className="space-y-5">{children}</div>
{error ? <ErrorMessage message={error} /> : null}
<div className="flex items-center justify-between gap-3 pt-2">
{backHref ? (
<Link to={backHref} className={SECONDARY_BUTTON_CLASS}>
<ArrowLeftIcon className="size-4 shrink-0" />
Back
</Link>
) : (
<span />
)}
<button type="submit" disabled={submitting} className={PRIMARY_BUTTON_CLASS}>
{submitting ? (
<>
<Spinner />
Saving
</>
) : (
<>
{submitLabel}
<ArrowRightIcon className="size-4 shrink-0" />
</>
)}
</button>
</div>
</form>
);
}
function ReviewScreen({
session,
error,
submitting,
onInstall,
}: {
session: InstallSessionResponse | null;
error: string | null;
submitting: boolean;
onInstall: () => Promise<void>;
}) {
const providers = (session?.llm?.providers ?? [])
.map((provider) => describeProvider(provider.provider))
.join(", ");
const serverUrl =
session?.server?.canonical_url || session?.prefill.canonical_url || "Unknown";
return (
<form
onSubmit={(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (submitting) return;
void onInstall();
}}
className="space-y-8"
>
<header>
<h1 className="text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
Review and install
</h1>
<p className="mt-3 max-w-[56ch] text-sm/6 text-fg-3 text-pretty">
Confirm the plan below. Fabro writes the configuration to disk, then
restarts into normal mode.
</p>
</header>
<dl className="divide-y divide-line border-y border-line">
<SummaryRow label="LLM providers" value={providers || "Not configured"} />
<SummaryRow
label="Server URL"
value={serverUrl}
mono
action={<CopyButton value={serverUrl} label="Copy server URL" />}
/>
{renderGithubSummaryRows(session?.github)}
</dl>
{error ? <ErrorMessage message={error} /> : null}
<div className="flex items-center justify-between gap-3 pt-2">
<Link to="/install/github" className={SECONDARY_BUTTON_CLASS}>
<ArrowLeftIcon className="size-4 shrink-0" />
Back
</Link>
<button type="submit" disabled={submitting} className={PRIMARY_BUTTON_CLASS}>
{submitting ? (
<>
<Spinner />
Installing
</>
) : (
"Install"
)}
</button>
</div>
</form>
);
}
function FinishingScreen({
finishState,
timedOut,
}: {
finishState: FinishState;
timedOut: boolean;
}) {
if (!finishState) {
return <Navigate to="/install/review" replace />;
}
return (
<div className="space-y-8">
<header>
<h1 className="text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
{timedOut ? "Install complete" : "Finishing up"}
</h1>
<p className="mt-3 max-w-[56ch] text-sm/6 text-fg-3 text-pretty">
{timedOut
? "The server didn't come back automatically. Start it manually and return to the URL below."
: "Configuration written. Waiting for the server to restart into normal mode."}
</p>
</header>
{timedOut ? (
<div className="rounded-lg bg-overlay px-4 py-3 text-sm/6 text-fg-2 outline-1 -outline-offset-1 outline-amber/30">
Run <code className="font-mono text-fg">fabro server start</code>, then
visit{" "}
<code className="font-mono text-fg">{finishState.restart_url}</code>.
</div>
) : (
<div className="flex items-center gap-3 rounded-lg bg-overlay px-4 py-3 outline-1 -outline-offset-1 outline-white/10">
<Spinner className="text-teal-300" />
<p className="text-sm/6 text-fg-3">
Polling <code className="font-mono text-fg-2">/health</code>…
</p>
</div>
)}
{finishState.dev_token ? (
<div className="rounded-lg bg-overlay p-4 outline-1 -outline-offset-1 outline-white/10">
<p className="text-xs font-semibold tracking-wide text-fg uppercase">
Development token
</p>
<p className="mt-1 text-sm/6 text-fg-3">
Use this to sign in after the server restarts.
</p>
<div className="mt-3">
<CopyableToken token={finishState.dev_token} />
</div>
</div>
) : null}
</div>
);
}
function ProviderFields({
value,
onChange,
}: {
value: ProviderSelection;
onChange: (nextValue: ProviderSelection) => void;
}) {
return (
<div className="space-y-6">
{INSTALL_PROVIDERS.map((provider) => {
const current = value[provider.id] ?? { apiKey: "" };
return (
<div key={provider.id}>
<label
htmlFor={`${provider.id}_api_key`}
className="text-sm font-medium text-fg"
>
{provider.label}
</label>
<div className="mt-2">
<PasswordInput
id={`${provider.id}_api_key`}
name={`${provider.id}_api_key`}
value={current.apiKey}
onChange={(next) =>
onChange({
...value,
[provider.id]: { ...current, apiKey: next },
})
}
placeholder={provider.envVar}
/>
</div>
<HelpDisclosure summary="Where do I get this?">
<p>{provider.keyHelp.text}</p>
<ExternalLink href={provider.keyHelp.url}>
{provider.keyHelp.url.replace(/^https?:\/\//, "")}
</ExternalLink>
</HelpDisclosure>
</div>
);
})}
</div>
);
}
function GithubStrategyPicker({
strategy,
onChange,
}: {
strategy: GithubStrategy;
onChange: (value: GithubStrategy) => void;
}) {
const options: Array<{ id: GithubStrategy; title: string; body: string }> = [
{
id: "token",
title: "Personal access token",
body: "Quickest path. Validates a PAT and stores it in the vault.",
},
{
id: "app",
title: "GitHub App",
body: "Recommended for teams. Enables OAuth.",
},
];
return (
<fieldset>
<legend className="text-sm font-medium text-fg">Authentication</legend>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{options.map((option) => (
<OptionCard
key={option.id}
selected={strategy === option.id}
onSelect={() => onChange(option.id)}
title={option.title}
body={option.body}
/>
))}
</div>
</fieldset>
);
}
function OwnerPicker({
ownerKind,
setOwnerKind,
}: {
ownerKind: GithubOwnerKind;
setOwnerKind: (value: GithubOwnerKind) => void;
}) {
const options: Array<{ id: GithubOwnerKind; title: string; body: string }> = [
{
id: "personal",
title: "Personal account",
body: "GitHub's personal app creation flow.",
},
{
id: "org",
title: "Organization",
body: "GitHub's org flow — requires the org slug.",
},
];
return (
<fieldset>
<legend className="text-sm font-medium text-fg">Owner</legend>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{options.map((option) => (
<OptionCard
key={option.id}
selected={ownerKind === option.id}
onSelect={() => setOwnerKind(option.id)}
title={option.title}
body={option.body}
/>
))}
</div>
</fieldset>
);
}
function OptionCard({
selected,
onSelect,
title,
body,
}: {
selected: boolean;
onSelect: () => void;
title: string;
body: string;
}) {
const base =
"group relative flex items-start gap-3 rounded-lg px-4 py-3.5 text-left outline-1 -outline-offset-1 transition-colors";
const state = selected
? "bg-teal-500/10 outline-teal-500/60"
: "bg-overlay outline-white/10 hover:bg-overlay-strong hover:outline-white/15";
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className={`${base} ${state}`}
>
<span
aria-hidden="true"
className={`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full outline-1 -outline-offset-1 ${
selected
? "bg-teal-500 outline-teal-500"
: "bg-transparent outline-white/20"
}`}
>
{selected ? (
<span className="size-1.5 rounded-full bg-navy-950" />
) : null}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-fg">{title}</span>
<span className="mt-1 block text-xs/5 text-fg-3">{body}</span>
</span>
</button>
);
}
function GithubAppDoneScreen({
github,
}: {
github: InstallSessionResponse["github"];
}) {
if (!github || github.strategy !== "app") {
return <Navigate to="/install/github" replace />;
}
return (
<div className="space-y-8">
<header>
<h1 className="text-2xl font-semibold tracking-tight text-fg text-balance sm:text-[1.75rem]">
GitHub App connected
</h1>
<p className="mt-3 max-w-[56ch] text-sm/6 text-fg-3 text-pretty">
The app credentials are staged. They'll be written into the runtime
env file when the install finishes.
</p>
</header>
<dl className="divide-y divide-line border-y border-line">
<SummaryRow label="Owner" value={describeGithubAppOwner(github.owner)} />
<SummaryRow
label="App"
value={github.slug || github.app_name || "GitHub App"}
mono
/>
<SummaryRow
label="Allowed user"
value={github.allowed_username || "Unknown"}
mono
/>
</dl>
<div className="flex justify-end">
<Link to="/install/review" className={PRIMARY_BUTTON_CLASS}>
Continue to review
<ArrowRightIcon className="size-4 shrink-0" />
</Link>
</div>
</div>
);
}
function SummaryRow({
label,
value,
mono,
action,
}: {
label: string;
value: string;
mono?: boolean;
action?: ReactNode;
}) {
return (
<div className="grid grid-cols-3 gap-4 py-4">
<dt className="text-sm text-fg-3">{label}</dt>
<dd className="col-span-2 flex items-start gap-2">
<span className={`min-w-0 flex-1 text-sm text-fg break-words ${mono ? "font-mono" : ""}`}>
{value}
</span>
{action}
</dd>
</div>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: ReactNode;
}) {
return (
<label className="block">
<div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
<span className="text-sm font-medium text-fg">{label}</span>
{hint ? <span className="text-xs text-fg-muted">{hint}</span> : null}
</div>
<div className="mt-2">{children}</div>
</label>
);
}
function PasswordInput({
id,
name,
value,
onChange,
placeholder,
}: {
id?: string;
name: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
const [visible, setVisible] = useState(false);
return (
<div className="relative">
<input
type={visible ? "text" : "password"}
id={id}
name={name}
value={value}
onChange={(event) => onChange(event.target.value)}
className={`${INPUT_CLASS} pr-11 font-mono`}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
/>
<button
type="button"
onClick={() => setVisible((current) => !current)}
className="absolute inset-y-0 right-0 flex items-center rounded-r-lg px-3 text-fg-muted outline-teal-500 hover:text-fg-2 focus-visible:outline-2 focus-visible:-outline-offset-2"
aria-label={visible ? "Hide value" : "Show value"}
>
{visible ? (
<EyeSlashIcon className="size-4" />
) : (
<EyeIcon className="size-4" />
)}
</button>
</div>
);
}
function CopyableToken({ token }: { token: string }) {
const [copied, setCopied] = useState(false);
return (
<div className="flex items-stretch gap-2">
<pre className="flex-1 overflow-x-auto rounded-md bg-panel-alt px-3 py-2 font-mono text-sm text-fg-2 outline-1 -outline-offset-1 outline-white/10">
<code>{token}</code>
</pre>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(token);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard may be blocked; leave state unchanged.
}
}}
className="inline-flex items-center gap-1.5 rounded-md bg-overlay px-3 text-xs font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay-strong focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
aria-label={copied ? "Copied" : "Copy token"}
>
{copied ? (
<ClipboardDocumentCheckIcon className="size-4 text-mint" />
) : (
<ClipboardIcon className="size-4" />
)}
<span>{copied ? "Copied" : "Copy"}</span>
</button>
</div>
);
}
function HelpDisclosure({
summary,
children,
}: {
summary: string;
children: ReactNode;
}) {
return (
<details className="group mt-2">
<summary className="inline-flex list-none items-center gap-1 rounded text-xs text-fg-3 outline-teal-500 select-none hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-2 [&::-webkit-details-marker]:hidden">
<ChevronDownIcon className="size-3.5 shrink-0 transition-transform group-open:-rotate-180" />
<span>{summary}</span>
</summary>
<div className="mt-2 space-y-1.5 text-xs/5 text-fg-3">{children}</div>
</details>
);
}
function ExternalLink({
href,
children,
}: {
href: string;
children: ReactNode;
}) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-mono text-teal-300 hover:text-teal-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 rounded"
>
{children}
<ArrowTopRightOnSquareIcon className="size-3 shrink-0" />
</a>
);
}
function Spinner({ className = "" }: { className?: string }) {
return (
<svg
className={`size-4 shrink-0 animate-spin ${className}`}
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
<path
d="M14 8a6 6 0 0 0-6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function defaultProviderSelection(): ProviderSelection {
return Object.fromEntries(
INSTALL_PROVIDERS.map((provider) => [provider.id, { apiKey: "" }]),
);
}
function hydrateProviderSelection(
current: ProviderSelection,
session: InstallSessionResponse,
): ProviderSelection {
const hasUserInput = Object.values(current).some((provider) => provider.apiKey);
if (hasUserInput) return current;
const next = defaultProviderSelection();
for (const provider of session.llm?.providers ?? []) {
next[provider.provider] = { apiKey: "" };
}
return next;
}
function describeProvider(id: string): string {
const match = INSTALL_PROVIDERS.find((provider) => provider.id === id);
return match?.label ?? id;
}
function renderGithubSummaryRows(
github: InstallSessionResponse["github"],
): ReactNode {
if (!github) {
return <SummaryRow label="GitHub" value="Not configured" />;
}
if (github.strategy === "app") {
return (
<>
<SummaryRow label="GitHub connection" value="GitHub App" />
<SummaryRow label="App owner" value={describeGithubAppOwner(github.owner)} />
<SummaryRow
label="Allowed user"
value={github.allowed_username ? `@${github.allowed_username}` : "Not set"}
mono={Boolean(github.allowed_username)}
/>
</>
);
}
return (
<>
<SummaryRow label="GitHub connection" value="Personal access token" />
<SummaryRow
label="User"
value={github.username ? `@${github.username}` : "Not set"}
mono={Boolean(github.username)}
/>
</>
);
}
function describeGithubAppOwner(
owner: InstallGithubAppOwner | undefined,
): string {
if (!owner || owner.kind === "personal") return "Personal account";
return owner.slug ? `@${owner.slug} (organization)` : "Organization";
}
function submitGithubManifest(
formAction: string,
manifest: Record<string, unknown>,
state: string,
): void {
const form = document.createElement("form");
form.method = "post";
form.action = formAction;
form.style.display = "none";
const manifestInput = document.createElement("input");
manifestInput.type = "hidden";
manifestInput.name = "manifest";
manifestInput.value = JSON.stringify(manifest);
form.appendChild(manifestInput);
const stateInput = document.createElement("input");
stateInput.type = "hidden";
stateInput.name = "state";
stateInput.value = state;
form.appendChild(stateInput);
document.body.appendChild(form);
form.submit();
}