repos: real provider-connection wizard

Replaces the earlier placeholder inside RepoConnectionWizardStub with the
actual flow (kept the filename so the Dashboard import doesn't churn).

- Provider picker (github / gitea / gitlab) as three inline cards
- PAT input (password field, never rendered back)
- Optional owner override (org or user)
- Optional label (defaults to <provider>/<owner>)
- Optional base URL — shown only for Gitea / GitLab, hidden for GitHub
- POST /api/repos/connections + immediate result card:
  green when the initial sync succeeded (shows # repos synced), red
  when the connection persisted but the sync failed (shows the message
  the backend recorded on repo_connections.last_sync_error). The
  sidebar refresh fires on both paths so the new row appears either way.

Sidebar and detail view already fetch the right endpoints from task
#11 — end-to-end works locally on this build.
This commit is contained in:
Omar Sobh
2026-07-07 14:54:39 -07:00
parent 6d087bf537
commit 9cb14ddd89
@@ -1,22 +1,96 @@
"use client";
// Placeholder wizard shell. The real provider connection flow lands in task
// #15 (frontend) once the /api/repos/connections routes exist. This exists
// so the tier shell has a working + button — it just tells the user the
// feature is coming.
// Real provider-connection wizard. Named …Stub for backwards-compat with the
// Dashboard.tsx import — the shell shipped first, this file is the real
// implementation. Renamed later.
//
// 1. Pick a provider (github / gitea / gitlab)
// 2. Paste a PAT (password field, never rendered back)
// 3. Optional owner override (org/user) and self-hosted base URL
// 4. Optional label — defaults to <provider>/<owner>
// 5. POST /api/repos/connections — the server stores the PAT in the broker,
// inserts the connection, and runs the first sync inline. We show the
// result and hand control back to the sidebar.
import { X } from "lucide-react";
import { useState } from "react";
import { GitBranch, KeyRound, Link2, X } from "lucide-react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
type Provider = "github" | "gitea" | "gitlab";
const PROVIDERS: {
key: Provider;
label: string;
hint: string;
needsBaseUrl: boolean;
defaultBase?: string;
}[] = [
{ key: "github", label: "GitHub", hint: "github.com or GHES", needsBaseUrl: false, defaultBase: "https://api.github.com" },
{ key: "gitea", label: "Gitea", hint: "self-hosted", needsBaseUrl: true, defaultBase: "https://git.example.com" },
{ key: "gitlab", label: "GitLab", hint: "gitlab.com or self-hosted", needsBaseUrl: true, defaultBase: "https://gitlab.com" },
];
interface CreateResponse {
id: string;
provider: string;
owner?: string | null;
synced: number;
sync_error?: string | null;
}
export function RepoConnectionWizardStub({
onClose,
onCreated: _onCreated,
onCreated,
}: {
onClose: () => void;
onCreated: () => void;
}) {
const [provider, setProvider] = useState<Provider>("github");
const [token, setToken] = useState("");
const [owner, setOwner] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [label, setLabel] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<CreateResponse | null>(null);
const providerMeta = PROVIDERS.find((p) => p.key === provider);
const canSubmit = token.trim().length > 0 && !submitting && !result;
async function submit() {
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch("/api/repos/connections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider,
token: token.trim(),
owner: owner.trim() || undefined,
base_url: baseUrl.trim() || undefined,
label: label.trim() || undefined,
}),
});
if (res.status !== 201) {
const body = await res.text().catch(() => "");
throw new Error(`connect failed (${res.status}) ${body}`.trim());
}
const data = (await res.json()) as CreateResponse;
setResult(data);
// If the sync ran fine, refresh the sidebar right away — otherwise let
// the user read the error message before we do.
if (!data.sync_error) onCreated();
} catch (e) {
setError(e instanceof Error ? e.message : "connect failed");
} finally {
setSubmitting(false);
}
}
return (
<div
onClick={onClose}
@@ -40,7 +114,7 @@ export function RepoConnectionWizardStub({
aria-label="Connect a provider"
style={{
width: "100%",
maxWidth: 460,
maxWidth: 520,
borderRadius: 16,
background: "#0d0d10",
border: "1px solid rgba(255,255,255,.1)",
@@ -74,29 +148,239 @@ export function RepoConnectionWizardStub({
<X size={14} />
</button>
</div>
<p style={{ fontFamily: mono, fontSize: 12, color: "#b5b5bd", lineHeight: 1.6, margin: 0 }}>
The provider-connection flow is landing next. It&apos;ll ask for the
provider (GitHub / Gitea / GitLab), a personal access token, and an
optional owner override then pull every repo the token can see.
</p>
{result ? (
<ResultCard
result={result}
onDone={() => {
onCreated();
onClose();
}}
/>
) : (
<>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={labelStyle}>Provider</span>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 6 }}>
{PROVIDERS.map((p) => {
const active = provider === p.key;
return (
<button
key={p.key}
type="button"
onClick={() => setProvider(p.key)}
style={{
textAlign: "left",
padding: "8px 10px",
borderRadius: 8,
background: active ? "rgba(255,111,97,.10)" : "rgba(255,255,255,.03)",
border: active ? "1px solid rgba(255,111,97,.4)" : "1px solid rgba(255,255,255,.08)",
color: "#eaeaee",
fontFamily: "inherit",
cursor: "pointer",
}}
>
<div style={{ fontSize: 12.5, fontWeight: 700 }}>{p.label}</div>
<div style={{ fontFamily: mono, fontSize: 10.5, color: active ? "#ff8a7a" : "#6a6a72", marginTop: 2 }}>
{p.hint}
</div>
</button>
);
})}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={labelStyle}>
<KeyRound size={11} style={{ marginRight: 4, verticalAlign: "-1px" }} />
Personal access token
</span>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={provider === "github" ? "ghp_… (needs `repo` scope for private)" : "PAT"}
style={fieldStyle}
autoComplete="off"
/>
<p style={hintStyle}>
Stored in the workspace secret broker never rendered back or
exposed to the app.
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={labelStyle}>Owner (optional)</span>
<input
value={owner}
onChange={(e) => setOwner(e.target.value)}
placeholder="org or username"
style={fieldStyle}
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={labelStyle}>Label (optional)</span>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={`${provider}${owner ? `/${owner}` : ""}`}
style={fieldStyle}
/>
</div>
</div>
{providerMeta?.needsBaseUrl ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={labelStyle}>
<Link2 size={11} style={{ marginRight: 4, verticalAlign: "-1px" }} />
Base URL
</span>
<input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={providerMeta.defaultBase}
style={{ ...fieldStyle, fontFamily: mono }}
/>
<p style={hintStyle}>
API base for your instance (e.g.{" "}
{providerMeta.defaultBase}). Leave blank to fall back to the
provider default.
</p>
</div>
) : null}
{error ? (
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a", margin: 0 }}>
{error}
</p>
) : null}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
type="button"
onClick={onClose}
disabled={submitting}
style={{
padding: "9px 16px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 12.5,
fontWeight: 600,
cursor: submitting ? "default" : "pointer",
}}
>
Cancel
</button>
<button
type="button"
onClick={submit}
disabled={!canSubmit}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "9px 16px",
borderRadius: 8,
border: 0,
background: canSubmit ? "#ff6f61" : "rgba(255,111,97,.3)",
color: "#1a0d0b",
fontSize: 12.5,
fontWeight: 700,
cursor: canSubmit ? "pointer" : "default",
}}
>
<GitBranch size={13} />
{submitting ? "Connecting…" : "Connect + sync"}
</button>
</div>
</>
)}
</div>
</div>
);
}
function ResultCard({
result,
onDone,
}: {
result: CreateResponse;
onDone: () => void;
}) {
const ok = !result.sync_error;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div
style={{
padding: "12px 14px",
borderRadius: 10,
border: `1px solid ${ok ? "rgba(127,208,160,.35)" : "rgba(255,138,122,.35)"}`,
background: ok ? "rgba(127,208,160,.06)" : "rgba(255,138,122,.06)",
color: "#eaeaee",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: ok ? "#7fd0a0" : "#ff8a7a" }}>
{ok ? `Connected · ${result.synced} repos synced` : "Connected but sync failed"}
</div>
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#b5b5bd" }}>
{result.provider}
{result.owner ? ` · ${result.owner}` : ""}
</div>
{result.sync_error ? (
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#ff8a7a" }}>
{result.sync_error}
</div>
) : null}
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button
type="button"
onClick={onClose}
onClick={onDone}
style={{
marginTop: 6,
padding: "9px 0",
padding: "9px 16px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
border: 0,
background: "#ff6f61",
color: "#1a0d0b",
fontSize: 12.5,
fontWeight: 600,
fontWeight: 700,
cursor: "pointer",
}}
>
Got it
Done
</button>
</div>
</div>
);
}
const labelStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
textTransform: "uppercase",
color: "#6a6a72",
};
const fieldStyle: React.CSSProperties = {
padding: "9px 11px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "#141417",
color: "#eaeaee",
fontSize: 12.5,
fontFamily: "inherit",
outline: 0,
};
const hintStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 10.5,
color: "#8a8a92",
margin: 0,
lineHeight: 1.5,
};