User can now click "Connect GitHub" inside the Import-existing-code
flow, sign in via GitHub, and pick a repo from a searchable list of
their own + collaborator + org repos. Both public and private repos
work — the encrypted access token on the user's account is auto-
attached when the create endpoint runs the agent-runner mirror.
OAuth flow:
- GET /api/integrations/github/connect — generates state, sets
a 10-min httpOnly cookie, 302s to GitHub authorize.
- GET /api/integrations/github/callback — verifies state,
exchanges code for token, fetches /user, encrypts the
token with secret-box (AES-256-GCM, VIBN_SECRETS_KEY) and
persists it on fs_users.data.integrations.github.
Bounces back to ?gh_connected=login or ?gh_error=msg.
- GET /api/integrations/github/repos — server-side fetches
the connected user's repos (per_page=100, sort=pushed,
affiliation=owner+collaborator+org_member). Returns the
GitHub login + a stripped repo summary; never the token.
- POST /api/integrations/github/disconnect — drops the integration
from fs_users (does NOT revoke on github.com).
Scopes requested: repo, read:user.
Token storage:
- Encrypted at rest with secret-box (lib/auth/secret-box.ts) using
VIBN_SECRETS_KEY. Tokens never leave the server.
- One token per fs_users row, keyed by email.
ImportSetup UI:
- On mount, fires /repos to detect connection state.
- If connected: shows a connected-as-@login chip with disconnect
link, a search-as-you-type repo picker (max 220px scroll, badges
for Private / language), and a "paste a different URL instead"
escape hatch.
- If not connected: shows a Connect GitHub card with a public-URL
fallback inline.
- On return from OAuth (?gh_connected=… or ?gh_error=…), surfaces
a toast and silently refreshes the repo list.
- Selected repo carries default_branch + repo id into the create
payload so we can store them on the project for later UI hints.
/api/projects/create:
- When a githubRepoUrl is mirrored, falls back to the user's
OAuth-linked token if no PAT is explicitly passed. Means the
flow "just works" for private repos once GitHub is connected.
Required env (already set in production):
- GITHUB_CLIENT_ID
- GITHUB_CLIENT_SECRET
Made-with: Cursor
498 lines
18 KiB
TypeScript
498 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { toast } from "sonner";
|
|
import { JM } from "./modal-theme";
|
|
import {
|
|
SetupHeader, FieldLabel, TextInput, TextArea, AudienceSelector,
|
|
PrimaryButton, SecondaryButton, StepDots,
|
|
type SetupProps, type Audience,
|
|
} from "./setup-shared";
|
|
|
|
/**
|
|
* "Import existing code" — two-step setup with GitHub OAuth.
|
|
*
|
|
* Step 1: project name + audience + repo source.
|
|
* Repo source defaults to the connected-GitHub picker; falls back to
|
|
* "paste a public URL" if the user hasn't linked GitHub yet (or
|
|
* prefers to bring an arbitrary repo).
|
|
* Step 2: optional "what do you want to do with it" textarea (seeds
|
|
* the AI's first message).
|
|
*/
|
|
export function ImportSetup({ workspace, onClose, onBack }: SetupProps) {
|
|
const router = useRouter();
|
|
const [step, setStep] = useState<0 | 1>(0);
|
|
const [name, setName] = useState("");
|
|
const [audience, setAudience] = useState<Audience>("customers");
|
|
|
|
// GitHub OAuth state. `connected === undefined` means we haven't
|
|
// checked yet; `null` means "checked, not linked".
|
|
const [connected, setConnected] = useState<undefined | null | { login: string; repos: GhRepo[] }>(undefined);
|
|
const [picker, setPicker] = useState<"github" | "url">("github");
|
|
const [filter, setFilter] = useState("");
|
|
const [selectedRepoId, setSelectedRepoId] = useState<number | null>(null);
|
|
const [manualUrl, setManualUrl] = useState("");
|
|
|
|
const [intent, setIntent] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
fetch("/api/integrations/github/repos", { credentials: "include" })
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (cancelled) return;
|
|
if (d?.connected && Array.isArray(d.repos)) {
|
|
setConnected({ login: d.login, repos: d.repos });
|
|
setPicker("github");
|
|
} else {
|
|
setConnected(null);
|
|
setPicker("url"); // no point defaulting to a picker that's empty
|
|
}
|
|
})
|
|
.catch(() => { if (!cancelled) setConnected(null); });
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
// Surface ?gh_error / ?gh_connected toasts after returning from GitHub.
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
const u = new URL(window.location.href);
|
|
const err = u.searchParams.get("gh_error");
|
|
const ok = u.searchParams.get("gh_connected");
|
|
if (err) {
|
|
toast.error(`GitHub: ${err}`);
|
|
u.searchParams.delete("gh_error");
|
|
window.history.replaceState({}, "", u.toString());
|
|
}
|
|
if (ok) {
|
|
toast.success(`Connected GitHub as @${ok}`);
|
|
u.searchParams.delete("gh_connected");
|
|
window.history.replaceState({}, "", u.toString());
|
|
// Refresh repo list silently.
|
|
fetch("/api/integrations/github/repos", { credentials: "include" })
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (d?.connected) setConnected({ login: d.login, repos: d.repos });
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
}, []);
|
|
|
|
const selectedRepo = connected && typeof connected === "object"
|
|
? connected.repos.find(r => r.id === selectedRepoId) ?? null
|
|
: null;
|
|
|
|
const isValidUrl = /^https?:\/\//i.test(manualUrl.trim());
|
|
const canContinue =
|
|
name.trim().length > 0 &&
|
|
((picker === "github" && !!selectedRepo) || (picker === "url" && isValidUrl));
|
|
|
|
const handleConnect = () => {
|
|
const returnTo = window.location.pathname + window.location.search;
|
|
window.location.href = `/api/integrations/github/connect?returnTo=${encodeURIComponent(returnTo)}`;
|
|
};
|
|
|
|
const handleDisconnect = async () => {
|
|
await fetch("/api/integrations/github/disconnect", { method: "POST", credentials: "include" });
|
|
setConnected(null);
|
|
setPicker("url");
|
|
setSelectedRepoId(null);
|
|
toast.success("Disconnected GitHub");
|
|
};
|
|
|
|
const handleCreate = async () => {
|
|
if (!canContinue) return;
|
|
const repoUrl = picker === "github" && selectedRepo ? selectedRepo.htmlUrl : manualUrl.trim();
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch("/api/projects/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
projectName: name.trim(),
|
|
projectType: "web-app",
|
|
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
|
vision: intent.trim() || `Continue work on ${repoUrl}`,
|
|
product: { name: name.trim() },
|
|
audience,
|
|
creationMode: "import",
|
|
githubRepoUrl: repoUrl,
|
|
githubDefaultBranch: selectedRepo?.defaultBranch ?? null,
|
|
githubRepoId: selectedRepo?.id ?? null,
|
|
sourceData: {
|
|
audience, repoUrl,
|
|
via: picker === "github" ? "oauth" : "url",
|
|
ghLogin: picker === "github" ? connected && typeof connected === "object" ? connected.login : null : null,
|
|
intent: intent.trim() || null,
|
|
},
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
toast.error(err.error || "Failed to create project");
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
onClose();
|
|
router.push(`/${workspace}/project/${data.projectId}/overview`);
|
|
} catch {
|
|
toast.error("Something went wrong");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const filteredRepos = connected && typeof connected === "object"
|
|
? connected.repos.filter(r => {
|
|
const q = filter.trim().toLowerCase();
|
|
if (!q) return true;
|
|
return r.fullName.toLowerCase().includes(q) || (r.description ?? "").toLowerCase().includes(q);
|
|
})
|
|
: [];
|
|
|
|
return (
|
|
<div style={{ padding: 28 }}>
|
|
<SetupHeader
|
|
icon="⌘" label="Import existing code" tagline="From GitHub"
|
|
accent="#1D4ED8" onBack={step === 0 ? onBack : () => setStep(0)} onClose={onClose}
|
|
/>
|
|
|
|
{step === 0 && (
|
|
<>
|
|
<FieldLabel>Project name</FieldLabel>
|
|
<TextInput
|
|
value={name}
|
|
onChange={setName}
|
|
placeholder="What do you want to call this?"
|
|
autoFocus
|
|
/>
|
|
|
|
<AudienceSelector value={audience} onChange={setAudience} />
|
|
|
|
{/* Repo source */}
|
|
{connected === undefined ? (
|
|
<div style={loadingBox}>Checking GitHub connection…</div>
|
|
) : connected === null ? (
|
|
<NotConnectedBlock
|
|
onConnect={handleConnect}
|
|
picker={picker}
|
|
setPicker={setPicker}
|
|
manualUrl={manualUrl}
|
|
setManualUrl={setManualUrl}
|
|
/>
|
|
) : (
|
|
<ConnectedBlock
|
|
login={connected.login}
|
|
picker={picker}
|
|
setPicker={setPicker}
|
|
repos={filteredRepos}
|
|
filter={filter}
|
|
setFilter={setFilter}
|
|
selectedRepoId={selectedRepoId}
|
|
setSelectedRepoId={setSelectedRepoId}
|
|
manualUrl={manualUrl}
|
|
setManualUrl={setManualUrl}
|
|
onDisconnect={handleDisconnect}
|
|
/>
|
|
)}
|
|
|
|
<FlowFooter step={0} total={2} primary={
|
|
<PrimaryButton onClick={() => setStep(1)} disabled={!canContinue}>
|
|
Next →
|
|
</PrimaryButton>
|
|
} />
|
|
</>
|
|
)}
|
|
|
|
{step === 1 && (
|
|
<>
|
|
<FieldLabel>What do you want to do with it? <span style={{ color: JM.muted, fontWeight: 400 }}>(optional)</span></FieldLabel>
|
|
<TextArea
|
|
value={intent}
|
|
onChange={setIntent}
|
|
placeholder="Add a payments page, fix the login bug, and clean up the dashboard."
|
|
rows={6}
|
|
autoFocus
|
|
/>
|
|
<p style={{ fontSize: 12, color: JM.muted, marginTop: -8, marginBottom: 18, lineHeight: 1.5 }}>
|
|
This tells Vibn where to start. Skip it and you can guide things in chat once the project's open.
|
|
</p>
|
|
|
|
<FlowFooter
|
|
step={1} total={2}
|
|
secondary={<SecondaryButton onClick={() => setStep(0)}>← Back</SecondaryButton>}
|
|
primary={
|
|
<PrimaryButton onClick={handleCreate} disabled={!canContinue} loading={loading}>
|
|
Import & open →
|
|
</PrimaryButton>
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Sub-blocks
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
interface GhRepo {
|
|
id: number;
|
|
name: string;
|
|
fullName: string;
|
|
description: string | null;
|
|
defaultBranch: string;
|
|
htmlUrl: string;
|
|
private: boolean;
|
|
language: string | null;
|
|
pushedAt: string | null;
|
|
fork: boolean;
|
|
}
|
|
|
|
function NotConnectedBlock({
|
|
onConnect, picker, setPicker, manualUrl, setManualUrl,
|
|
}: {
|
|
onConnect: () => void;
|
|
picker: "github" | "url";
|
|
setPicker: (p: "github" | "url") => void;
|
|
manualUrl: string;
|
|
setManualUrl: (s: string) => void;
|
|
}) {
|
|
return (
|
|
<>
|
|
<FieldLabel>Where's the code?</FieldLabel>
|
|
<div style={{
|
|
padding: 14, borderRadius: 10, border: `1px solid ${JM.border}`,
|
|
background: JM.cream, marginBottom: 14,
|
|
display: "flex", alignItems: "center", gap: 12,
|
|
}}>
|
|
<div style={{
|
|
width: 34, height: 34, borderRadius: 8, background: "#1A1A1A",
|
|
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
|
}}>
|
|
<GhMark />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: 13, fontWeight: 600, color: JM.ink }}>Connect your GitHub</div>
|
|
<div style={{ fontSize: 11.5, color: JM.mid, marginTop: 1 }}>
|
|
Pick from a list of your repos — public or private.
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onConnect}
|
|
style={{
|
|
padding: "7px 13px", borderRadius: 7,
|
|
background: "#1A1A1A", color: "#fff", border: "none",
|
|
fontSize: 12.5, fontWeight: 600, cursor: "pointer",
|
|
fontFamily: JM.fontSans, flexShrink: 0,
|
|
}}
|
|
>
|
|
Connect →
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setPicker(picker === "url" ? "github" : "url")}
|
|
style={linkButton}
|
|
>
|
|
{picker === "url" ? "I'd rather connect GitHub" : "Or paste a public repo URL instead →"}
|
|
</button>
|
|
|
|
{picker === "url" && (
|
|
<div style={{ marginTop: 10 }}>
|
|
<FieldLabel>Public GitHub URL</FieldLabel>
|
|
<TextInput
|
|
value={manualUrl}
|
|
onChange={setManualUrl}
|
|
placeholder="https://github.com/yourname/your-repo"
|
|
/>
|
|
<p style={{ fontSize: 11.5, color: JM.muted, marginTop: -8, marginBottom: 14, lineHeight: 1.5 }}>
|
|
Public repos work without connecting GitHub. Private repos need the connection above.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ConnectedBlock({
|
|
login, picker, setPicker, repos, filter, setFilter,
|
|
selectedRepoId, setSelectedRepoId, manualUrl, setManualUrl, onDisconnect,
|
|
}: {
|
|
login: string;
|
|
picker: "github" | "url";
|
|
setPicker: (p: "github" | "url") => void;
|
|
repos: GhRepo[];
|
|
filter: string; setFilter: (s: string) => void;
|
|
selectedRepoId: number | null;
|
|
setSelectedRepoId: (n: number | null) => void;
|
|
manualUrl: string;
|
|
setManualUrl: (s: string) => void;
|
|
onDisconnect: () => void;
|
|
}) {
|
|
return (
|
|
<>
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 8,
|
|
padding: "8px 12px", borderRadius: 8,
|
|
background: JM.cream, border: `1px solid ${JM.border}`,
|
|
marginBottom: 12, fontSize: 12, color: JM.mid, fontFamily: JM.fontSans,
|
|
}}>
|
|
<GhMark size={14} dark />
|
|
<span>Connected as <strong style={{ color: JM.ink, fontWeight: 600 }}>@{login}</strong></span>
|
|
<div style={{ flex: 1 }} />
|
|
<button type="button" onClick={onDisconnect} style={textBtn}>Disconnect</button>
|
|
</div>
|
|
|
|
{picker === "github" ? (
|
|
<>
|
|
<FieldLabel>Pick a repository</FieldLabel>
|
|
<input
|
|
type="text"
|
|
value={filter}
|
|
onChange={e => setFilter(e.target.value)}
|
|
placeholder={`Search ${repos.length > 0 ? `${repos.length} repos…` : "repos…"}`}
|
|
style={{
|
|
width: "100%", padding: "9px 12px", marginBottom: 8,
|
|
borderRadius: 7, border: `1px solid ${JM.border}`,
|
|
background: JM.inputBg, fontSize: 13,
|
|
fontFamily: JM.fontSans, color: JM.ink,
|
|
outline: "none", boxSizing: "border-box",
|
|
}}
|
|
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
|
|
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
|
|
/>
|
|
<div style={{
|
|
maxHeight: 220, overflowY: "auto",
|
|
border: `1px solid ${JM.border}`, borderRadius: 8,
|
|
background: "#fff", marginBottom: 12,
|
|
}}>
|
|
{repos.length === 0 ? (
|
|
<div style={{ padding: 18, fontSize: 12.5, color: JM.muted, textAlign: "center" }}>
|
|
No matching repos
|
|
</div>
|
|
) : (
|
|
repos.map(r => {
|
|
const sel = r.id === selectedRepoId;
|
|
return (
|
|
<button
|
|
key={r.id}
|
|
type="button"
|
|
onClick={() => setSelectedRepoId(r.id)}
|
|
style={{
|
|
width: "100%", textAlign: "left",
|
|
padding: "9px 12px",
|
|
border: "none", borderBottom: `1px solid ${JM.border}`,
|
|
background: sel ? "#EEF2FF" : "transparent",
|
|
cursor: "pointer", fontFamily: JM.fontSans,
|
|
display: "flex", alignItems: "center", gap: 8,
|
|
}}
|
|
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = JM.cream; }}
|
|
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = "transparent"; }}
|
|
>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{
|
|
fontSize: 13, fontWeight: 600, color: JM.ink,
|
|
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
|
}}>
|
|
{r.fullName}
|
|
</div>
|
|
{r.description && (
|
|
<div style={{
|
|
fontSize: 11.5, color: JM.mid, marginTop: 1,
|
|
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
|
}}>
|
|
{r.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div style={{ display: "flex", gap: 5, flexShrink: 0 }}>
|
|
{r.private && <Badge>Private</Badge>}
|
|
{r.language && <Badge muted>{r.language}</Badge>}
|
|
</div>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
<button type="button" onClick={() => setPicker("url")} style={linkButton}>
|
|
Or paste a different URL →
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<FieldLabel>Public repo URL</FieldLabel>
|
|
<TextInput
|
|
value={manualUrl}
|
|
onChange={setManualUrl}
|
|
placeholder="https://github.com/yourname/your-repo"
|
|
/>
|
|
<button type="button" onClick={() => setPicker("github")} style={linkButton}>
|
|
← Back to your repos
|
|
</button>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Badge({ children, muted }: { children: React.ReactNode; muted?: boolean }) {
|
|
return (
|
|
<span style={{
|
|
fontSize: 10, fontWeight: 600, padding: "2px 7px", borderRadius: 4,
|
|
background: muted ? "#F3F4F6" : "#FEF3C7",
|
|
color: muted ? "#6B7280" : "#92400E",
|
|
letterSpacing: "0.02em", fontFamily: JM.fontSans,
|
|
}}>{children}</span>
|
|
);
|
|
}
|
|
|
|
function GhMark({ size = 18, dark }: { size?: number; dark?: boolean }) {
|
|
return (
|
|
<svg width={size} height={size} viewBox="0 0 16 16" fill={dark ? "#1A1A1A" : "#fff"}>
|
|
<path fillRule="evenodd" d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38v-1.34c-2.23.48-2.7-1.07-2.7-1.07-.36-.92-.89-1.17-.89-1.17-.73-.5.05-.49.05-.49.81.06 1.24.83 1.24.83.72 1.23 1.88.87 2.34.66.07-.52.28-.87.5-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.13 0 0 .67-.21 2.2.82a7.6 7.6 0 014 0c1.53-1.04 2.2-.82 2.2-.82.44 1.11.16 1.93.08 2.13.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48v2.2c0 .21.15.46.55.38A8.001 8.001 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function FlowFooter({
|
|
step, total, primary, secondary,
|
|
}: {
|
|
step: number; total: number;
|
|
primary: React.ReactNode; secondary?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 6 }}>
|
|
<StepDots step={step} total={total} />
|
|
<div style={{ flex: 1 }} />
|
|
{secondary}
|
|
<div style={{ minWidth: 160 }}>{primary}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const linkButton: React.CSSProperties = {
|
|
background: "none", border: "none", padding: 0,
|
|
fontSize: 12, color: JM.indigo, cursor: "pointer",
|
|
fontFamily: JM.fontSans, fontWeight: 500,
|
|
marginBottom: 14,
|
|
};
|
|
|
|
const textBtn: React.CSSProperties = {
|
|
background: "none", border: "none", padding: 0,
|
|
fontSize: 11.5, color: JM.muted, cursor: "pointer",
|
|
fontFamily: JM.fontSans,
|
|
textDecoration: "underline",
|
|
};
|
|
|
|
const loadingBox: React.CSSProperties = {
|
|
padding: "14px 16px", borderRadius: 8,
|
|
background: JM.cream, border: `1px solid ${JM.border}`,
|
|
fontSize: 12.5, color: JM.mid, marginBottom: 14, fontFamily: JM.fontSans,
|
|
};
|