chore: convert submodules to standard directories for true monorepo structure
This commit is contained in:
78
vibn-frontend/app/api/integrations/github/callback/route.ts
Normal file
78
vibn-frontend/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 = (process.env.NEXTAUTH_URL ?? url.origin).replace(/\/$/, "");
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
56
vibn-frontend/app/api/integrations/github/connect/route.ts
Normal file
56
vibn-frontend/app/api/integrations/github/connect/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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);
|
||||
// Use NEXTAUTH_URL when available — behind a proxy req.url.origin
|
||||
// resolves to the internal bind address (0.0.0.0) rather than the
|
||||
// public hostname, which GitHub then rejects as an unregistered URI.
|
||||
const appOrigin = (process.env.NEXTAUTH_URL ?? url.origin).replace(/\/$/, "");
|
||||
const callbackUrl = `${appOrigin}/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;
|
||||
}
|
||||
@@ -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
vibn-frontend/app/api/integrations/github/repos/route.ts
Normal file
53
vibn-frontend/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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user