feat(github): OAuth integration + repo picker for Import flow

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
This commit is contained in:
2026-04-29 16:44:13 -07:00
parent c7bb0eea58
commit 90bed6ab31
7 changed files with 773 additions and 22 deletions

View File

@@ -0,0 +1,78 @@
/**
* GET /api/integrations/github/callback?code=…&state=…
*
* Final step of the OAuth dance:
* 1. Validate the `state` matches what we stored in the cookie.
* 2. Exchange `code` for an access token.
* 3. Read the GitHub /user endpoint to confirm + capture login.
* 4. Encrypt the token and persist on fs_users.data.integrations.github.
* 5. Redirect back to the `returnTo` URL we stashed in the state cookie.
*
* On any failure we redirect back to /projects with ?gh_error=… so the
* UI can surface the message in a toast.
*/
import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import {
exchangeCodeForToken, getAuthenticatedUser, persistGithubIntegration,
isGithubOauthConfigured,
} from "@/lib/integrations/github";
const STATE_COOKIE = "gh_oauth_state";
function bounce(origin: string, returnTo: string, params: Record<string, string>): NextResponse {
const dest = new URL(returnTo.startsWith("/") ? returnTo : "/", origin);
for (const [k, v] of Object.entries(params)) dest.searchParams.set(k, v);
const res = NextResponse.redirect(dest);
// Clear the one-shot state cookie so it can't be replayed.
res.cookies.set(STATE_COOKIE, "", { path: "/", maxAge: 0 });
return res;
}
export async function GET(req: Request) {
const url = new URL(req.url);
const origin = url.origin;
// Recover the original target from the state cookie *before* any error path.
const cookieState = req.headers.get("cookie")
?.split(";").map(c => c.trim())
.find(c => c.startsWith(`${STATE_COOKIE}=`))
?.split("=")[1] ?? "";
const [storedState, storedReturnTo = "/"] = decodeURIComponent(cookieState).split(":");
if (!isGithubOauthConfigured()) {
return bounce(origin, storedReturnTo, { gh_error: "GitHub OAuth not configured" });
}
const session = await authSession();
if (!session?.user?.email) {
return bounce(origin, "/auth", { gh_error: "Sign in first" });
}
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const errParam = url.searchParams.get("error_description") ?? url.searchParams.get("error");
if (errParam) {
return bounce(origin, storedReturnTo, { gh_error: errParam });
}
if (!code || !state) {
return bounce(origin, storedReturnTo, { gh_error: "Missing code or state" });
}
if (!storedState || storedState !== state) {
return bounce(origin, storedReturnTo, { gh_error: "State mismatch (try again)" });
}
try {
const callbackUrl = `${origin}/api/integrations/github/callback`;
const tok = await exchangeCodeForToken(code, callbackUrl);
const me = await getAuthenticatedUser(tok.accessToken);
await persistGithubIntegration(session.user.email, me.login, tok.accessToken, tok.scope);
return bounce(origin, storedReturnTo, { gh_connected: me.login });
} catch (err) {
const msg = err instanceof Error ? err.message : "GitHub connect failed";
console.error("[github callback]", err);
return bounce(origin, storedReturnTo, { gh_error: msg });
}
}

View File

@@ -0,0 +1,52 @@
/**
* GET /api/integrations/github/connect
*
* Kicks off GitHub OAuth. Generates an unguessable `state`, stores it
* in a short-lived (10 min) httpOnly cookie, and 302s to GitHub's
* authorize endpoint.
*
* The callback route validates the cookie matches the `state` GitHub
* echoes back, defending against login-CSRF.
*/
import { NextResponse } from "next/server";
import { randomBytes } from "crypto";
import { authSession } from "@/lib/auth/session-server";
import {
buildAuthorizeUrl, isGithubOauthConfigured,
} from "@/lib/integrations/github";
const STATE_COOKIE = "gh_oauth_state";
const STATE_TTL_S = 10 * 60;
export async function GET(req: Request) {
if (!isGithubOauthConfigured()) {
return NextResponse.json(
{ error: "GitHub OAuth is not configured on this server." },
{ status: 503 },
);
}
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const callbackUrl = `${url.origin}/api/integrations/github/callback`;
const returnTo = url.searchParams.get("returnTo") ?? "/";
const state = randomBytes(16).toString("hex");
const statePayload = `${state}:${returnTo}`;
const authorize = buildAuthorizeUrl(state, callbackUrl);
const res = NextResponse.redirect(authorize);
res.cookies.set(STATE_COOKIE, statePayload, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: url.protocol === "https:",
maxAge: STATE_TTL_S,
});
return res;
}

View File

@@ -0,0 +1,20 @@
/**
* POST /api/integrations/github/disconnect
*
* Drops the stored GitHub access token + login from the user's
* fs_users record. Does NOT revoke the OAuth grant on GitHub itself —
* users can do that from https://github.com/settings/applications.
*/
import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { disconnectGithubIntegration } from "@/lib/integrations/github";
export async function POST() {
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await disconnectGithubIntegration(session.user.email);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,53 @@
/**
* GET /api/integrations/github/repos
*
* Returns the connected user's GitHub repos for the import-flow picker.
* Server-side: never exposes the raw token, only the public repo
* metadata the picker needs (name, full_name, description, language,
* is-private, last-pushed, default branch, html_url).
*
* 200 → { connected: true, login, repos: [...] }
* 200 → { connected: false } ← user hasn't linked GitHub yet
* 401 → unauthorized
*/
import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { loadGithubIntegration, listUserRepos } from "@/lib/integrations/github";
export async function GET() {
const session = await authSession();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const link = await loadGithubIntegration(session.user.email);
if (!link) {
return NextResponse.json({ connected: false });
}
try {
const repos = await listUserRepos(link.token);
return NextResponse.json({
connected: true,
login: link.login,
repos: repos.map(r => ({
id: r.id,
name: r.name,
fullName: r.full_name,
description: r.description,
defaultBranch: r.default_branch,
htmlUrl: r.html_url,
private: r.private,
language: r.language,
pushedAt: r.pushed_at,
fork: r.fork,
})),
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to list repos" },
{ status: 502 },
);
}
}

View File

@@ -5,6 +5,7 @@ import { randomUUID } from 'crypto';
import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea';
import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces';
import { ensureProjectCoolifyProject } from '@/lib/projects';
import { loadGithubIntegration } from '@/lib/integrations/github';
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT;
@@ -137,6 +138,14 @@ export async function POST(request: Request) {
// because most projects don't need a 4-app monorepo and the AI
// can scaffold whatever the user actually wants on demand.
if (githubRepoUrl) {
// Prefer an explicitly-passed token; otherwise fall back to the
// OAuth-linked token on this user's account so private mirrors
// work without the user pasting a PAT.
let effectiveToken = githubToken as string | undefined;
if (!effectiveToken) {
const link = await loadGithubIntegration(email);
if (link) effectiveToken = link.token;
}
const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
const mirrorRes = await fetch(`${agentRunnerUrl}/api/mirror`, {
method: 'POST',
@@ -145,7 +154,7 @@ export async function POST(request: Request) {
github_url: githubRepoUrl,
gitea_repo: `${repoOwner}/${repoName}`,
project_name: projectName,
github_token: githubToken || undefined,
github_token: effectiveToken || undefined,
}),
});
if (!mirrorRes.ok) {

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
@@ -11,27 +11,100 @@ import {
} from "./setup-shared";
/**
* "Import existing code" — two-step setup.
* Step 1: project name + audience + repo URL.
* Step 2: describe what you want Vibn to focus on (optional, but
* recommended). Becomes the seed for the first AI conversation.
* "Import existing code" — two-step setup with GitHub OAuth.
*
* v1: public repos only. Private-repo OAuth lands in v2.
* 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");
const [repoUrl, setRepoUrl] = useState("");
// 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);
const isValidUrl = /^https?:\/\//i.test(repoUrl.trim());
const canContinue = name.trim().length > 0 && isValidUrl;
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", {
@@ -41,12 +114,19 @@ export function ImportSetup({ workspace, onClose, onBack }: SetupProps) {
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
vision: intent.trim() || `Continue work on ${repoUrl.trim()}`,
vision: intent.trim() || `Continue work on ${repoUrl}`,
product: { name: name.trim() },
audience,
creationMode: "import",
githubRepoUrl: repoUrl.trim(),
sourceData: { audience, repoUrl: repoUrl.trim(), intent: intent.trim() || null },
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) {
@@ -64,6 +144,14 @@ export function ImportSetup({ workspace, onClose, onBack }: SetupProps) {
}
};
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
@@ -83,15 +171,32 @@ export function ImportSetup({ workspace, onClose, onBack }: SetupProps) {
<AudienceSelector value={audience} onChange={setAudience} />
<FieldLabel>GitHub repository link</FieldLabel>
<TextInput
value={repoUrl}
onChange={setRepoUrl}
placeholder="https://github.com/yourname/your-repo"
/>
<p style={{ fontSize: 12, color: JM.muted, marginTop: -8, marginBottom: 18, lineHeight: 1.5 }}>
Public repos work today. Private-repo support is coming soon.
</p>
{/* 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}>
@@ -130,6 +235,231 @@ export function ImportSetup({ workspace, onClose, onBack }: SetupProps) {
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 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,
}: {
@@ -145,3 +475,23 @@ function FlowFooter({
</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,
};

189
lib/integrations/github.ts Normal file
View File

@@ -0,0 +1,189 @@
/**
* GitHub OAuth integration helpers.
*
* Storage layout — fs_users.data.integrations.github:
* {
* login: "octocat",
* accessToken: <encryptSecret(token)>, // encrypted at rest
* scope: "repo,read:user",
* connectedAt: ISO,
* }
*
* Tokens never leave the server. All API calls happen server-side; the
* UI only ever sees the GitHub `login` and the list of repos.
*
* Scopes requested: `repo` (private+public read+write so we can mirror)
* `read:user` (so we can show the connected username)
*/
import { encryptSecret, decryptSecret } from "@/lib/auth/secret-box";
import { query } from "@/lib/db-postgres";
const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? "";
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? "";
const SCOPES = "repo,read:user";
export function isGithubOauthConfigured(): boolean {
return CLIENT_ID.length > 0 && CLIENT_SECRET.length > 0;
}
/** Build the GitHub authorize URL. `state` MUST be unguessable per request. */
export function buildAuthorizeUrl(state: string, callbackUrl: string): string {
const u = new URL("https://github.com/login/oauth/authorize");
u.searchParams.set("client_id", CLIENT_ID);
u.searchParams.set("redirect_uri", callbackUrl);
u.searchParams.set("scope", SCOPES);
u.searchParams.set("state", state);
u.searchParams.set("allow_signup", "true");
return u.toString();
}
interface TokenExchangeResult {
accessToken: string;
scope: string;
tokenType: string;
}
/** POST /login/oauth/access_token — exchange auth code for an access token. */
export async function exchangeCodeForToken(
code: string, callbackUrl: string,
): Promise<TokenExchangeResult> {
const res = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
redirect_uri: callbackUrl,
}),
});
const body = await res.json() as {
access_token?: string; scope?: string; token_type?: string;
error?: string; error_description?: string;
};
if (!res.ok || !body.access_token) {
throw new Error(body.error_description || body.error || "GitHub token exchange failed");
}
return {
accessToken: body.access_token,
scope: body.scope ?? "",
tokenType: body.token_type ?? "bearer",
};
}
/** GET /user — fetch the authenticated user's login + name. */
export async function getAuthenticatedUser(token: string) {
const res = await fetch("https://api.github.com/user", {
headers: {
"Authorization": `Bearer ${token}`,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!res.ok) {
throw new Error(`GitHub /user failed: ${res.status} ${res.statusText}`);
}
return res.json() as Promise<{ login: string; name: string | null; avatar_url: string }>;
}
interface GithubRepo {
id: number;
name: string;
full_name: string;
private: boolean;
description: string | null;
default_branch: string;
html_url: string;
pushed_at: string | null;
language: string | null;
fork: boolean;
}
/**
* GET /user/repos — list repos the authenticated user can access.
* Returns at most `perPage` repos; we cap at 100 (GitHub max) and don't
* paginate further. The picker UI does client-side filter on top.
*/
export async function listUserRepos(token: string, perPage = 100): Promise<GithubRepo[]> {
const u = new URL("https://api.github.com/user/repos");
u.searchParams.set("per_page", String(perPage));
u.searchParams.set("sort", "pushed");
u.searchParams.set("affiliation", "owner,collaborator,organization_member");
const res = await fetch(u.toString(), {
headers: {
"Authorization": `Bearer ${token}`,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!res.ok) {
throw new Error(`GitHub /user/repos failed: ${res.status} ${res.statusText}`);
}
return res.json() as Promise<GithubRepo[]>;
}
// ─────────────────────────────────────────────────────────────────────────────
// fs_users persistence
// ─────────────────────────────────────────────────────────────────────────────
export interface StoredGithubIntegration {
login: string;
accessToken: string; // encrypted
scope: string;
connectedAt: string;
}
/** Write the integration to fs_users.data.integrations.github. */
export async function persistGithubIntegration(
email: string, login: string, plainToken: string, scope: string,
): Promise<void> {
const blob: StoredGithubIntegration = {
login,
accessToken: encryptSecret(plainToken),
scope,
connectedAt: new Date().toISOString(),
};
await query(
`UPDATE fs_users
SET data = jsonb_set(
jsonb_set(data, '{integrations}', COALESCE(data->'integrations','{}'::jsonb), true),
'{integrations,github}', $2::jsonb, true
),
updated_at = NOW()
WHERE data->>'email' = $1`,
[email, JSON.stringify(blob)],
);
}
/** Read + decrypt the GitHub access token for a user, if any. */
export async function loadGithubIntegration(
email: string,
): Promise<{ login: string; token: string; scope: string } | null> {
const rows = await query<{ blob: StoredGithubIntegration | null }>(
`SELECT data->'integrations'->'github' AS blob
FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[email],
);
const blob = rows[0]?.blob;
if (!blob || !blob.accessToken) return null;
try {
return { login: blob.login, token: decryptSecret(blob.accessToken), scope: blob.scope };
} catch {
return null;
}
}
/** Drop the stored integration. */
export async function disconnectGithubIntegration(email: string): Promise<void> {
await query(
`UPDATE fs_users
SET data = data #- '{integrations,github}',
updated_at = NOW()
WHERE data->>'email' = $1`,
[email],
);
}