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:
78
app/api/integrations/github/callback/route.ts
Normal file
78
app/api/integrations/github/callback/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
52
app/api/integrations/github/connect/route.ts
Normal file
52
app/api/integrations/github/connect/route.ts
Normal 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;
|
||||
}
|
||||
20
app/api/integrations/github/disconnect/route.ts
Normal file
20
app/api/integrations/github/disconnect/route.ts
Normal 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 });
|
||||
}
|
||||
53
app/api/integrations/github/repos/route.ts
Normal file
53
app/api/integrations/github/repos/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
189
lib/integrations/github.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user