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:
@@ -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'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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user