From 249839199f2cdb2d26b0a9f599359b93b21fa276 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 22:06:37 +0000 Subject: [PATCH 1/9] feat: enhance UI and add GitLab + Bitbucket platform integrations - Landing page: add multi-platform sign-in CTAs (GitHub, GitLab, Bitbucket), platform logo strip, platform deep-dive section, and improved footer with links - Repositories page: replace single-platform view with tabbed UI supporting GitHub, GitLab, and Bitbucket simultaneously; green dot indicates connected platforms; import button aggregates selections across all tabs - Dashboard header: add mobile hamburger menu with full-screen drawer nav - lib/platform-config.ts: add Bitbucket platform, add color field to all platforms - app/api/auth/gitlab/{login,callback}: GitLab OAuth 2.0 flow with state cookie - app/api/auth/bitbucket/{login,callback}: Bitbucket OAuth 2.0 with HTTP Basic Auth - app/api/gitlab/repos: paginated GitLab project listing via access token cookie - app/api/bitbucket/repos: paginated Bitbucket repository listing via access token cookie To activate GitLab: set GITLAB_CLIENT_ID + GITLAB_CLIENT_SECRET env vars. To activate Bitbucket: set BITBUCKET_CLIENT_ID + BITBUCKET_CLIENT_SECRET env vars. https://claude.ai/code/session_01X3LqF9XU1ccQbcrReQzEtR --- app/api/auth/bitbucket/callback/route.ts | 81 +++ app/api/auth/bitbucket/login/route.ts | 38 ++ app/api/auth/gitlab/callback/route.ts | 78 +++ app/api/auth/gitlab/login/route.ts | 36 ++ app/api/bitbucket/repos/route.ts | 72 +++ app/api/gitlab/repos/route.ts | 77 +++ app/page.tsx | 265 ++++++++-- components/dashboard-header.tsx | 109 +++- components/repositories-list.tsx | 601 +++++++++++++++-------- lib/platform-config.ts | 44 +- 10 files changed, 1135 insertions(+), 266 deletions(-) create mode 100644 app/api/auth/bitbucket/callback/route.ts create mode 100644 app/api/auth/bitbucket/login/route.ts create mode 100644 app/api/auth/gitlab/callback/route.ts create mode 100644 app/api/auth/gitlab/login/route.ts create mode 100644 app/api/bitbucket/repos/route.ts create mode 100644 app/api/gitlab/repos/route.ts diff --git a/app/api/auth/bitbucket/callback/route.ts b/app/api/auth/bitbucket/callback/route.ts new file mode 100644 index 0000000..45f5419 --- /dev/null +++ b/app/api/auth/bitbucket/callback/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +function getBaseUrl(request: NextRequest) { + return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + const cookieStore = await cookies() + const savedState = cookieStore.get('bitbucket_oauth_state')?.value + + if (error) { + return NextResponse.redirect(new URL('/?error=bitbucket_oauth_failed', getBaseUrl(request))) + } + + if (!code) { + return NextResponse.redirect(new URL('/?error=missing_code', getBaseUrl(request))) + } + + if (!state || !savedState || state !== savedState) { + return NextResponse.redirect(new URL('/?error=invalid_oauth_state', getBaseUrl(request))) + } + + const clientId = process.env.BITBUCKET_CLIENT_ID + const clientSecret = process.env.BITBUCKET_CLIENT_SECRET + + if (!clientId || !clientSecret) { + return NextResponse.redirect(new URL('/?error=bitbucket_oauth_not_configured', getBaseUrl(request))) + } + + const redirectUri = `${getBaseUrl(request)}/api/auth/bitbucket/callback` + + // Bitbucket uses HTTP Basic Auth for token exchange + const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64') + const tokenResponse = await fetch('https://bitbucket.org/site/oauth2/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${credentials}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + }).toString(), + }) + + if (!tokenResponse.ok) { + return NextResponse.redirect(new URL('/?error=token_exchange_failed', getBaseUrl(request))) + } + + const tokenJson = (await tokenResponse.json()) as { access_token?: string; error?: string } + const access_token = tokenJson.access_token + + if (!access_token) { + return NextResponse.redirect(new URL('/?error=token_exchange_failed', getBaseUrl(request))) + } + + const response = NextResponse.redirect( + new URL('/dashboard/repositories?connected=bitbucket', getBaseUrl(request)) + ) + + response.cookies.set('bitbucket_access_token', access_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 30, + }) + response.cookies.set('bitbucket_oauth_state', '', { path: '/', maxAge: 0 }) + + return response + } catch { + return NextResponse.redirect(new URL('/?error=oauth_callback_failed', getBaseUrl(request))) + } +} diff --git a/app/api/auth/bitbucket/login/route.ts b/app/api/auth/bitbucket/login/route.ts new file mode 100644 index 0000000..7eba71d --- /dev/null +++ b/app/api/auth/bitbucket/login/route.ts @@ -0,0 +1,38 @@ +import crypto from 'node:crypto' +import { NextRequest, NextResponse } from 'next/server' + +function getBaseUrl(request: NextRequest) { + return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin +} + +export async function GET(request: NextRequest) { + const clientId = process.env.BITBUCKET_CLIENT_ID + + if (!clientId) { + return NextResponse.redirect(new URL('/?error=bitbucket_oauth_not_configured', getBaseUrl(request))) + } + + const state = crypto.randomUUID() + const redirectUri = `${getBaseUrl(request)}/api/auth/bitbucket/callback` + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'repository account', + state, + }) + + const response = NextResponse.redirect( + `https://bitbucket.org/site/oauth2/authorize?${params.toString()}` + ) + response.cookies.set('bitbucket_oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, + }) + + return response +} diff --git a/app/api/auth/gitlab/callback/route.ts b/app/api/auth/gitlab/callback/route.ts new file mode 100644 index 0000000..0d7fc82 --- /dev/null +++ b/app/api/auth/gitlab/callback/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +function getBaseUrl(request: NextRequest) { + return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + const cookieStore = await cookies() + const savedState = cookieStore.get('gitlab_oauth_state')?.value + + if (error) { + return NextResponse.redirect(new URL(`/?error=gitlab_oauth_failed`, getBaseUrl(request))) + } + + if (!code) { + return NextResponse.redirect(new URL('/?error=missing_code', getBaseUrl(request))) + } + + if (!state || !savedState || state !== savedState) { + return NextResponse.redirect(new URL('/?error=invalid_oauth_state', getBaseUrl(request))) + } + + const clientId = process.env.GITLAB_CLIENT_ID + const clientSecret = process.env.GITLAB_CLIENT_SECRET + + if (!clientId || !clientSecret) { + return NextResponse.redirect(new URL('/?error=gitlab_oauth_not_configured', getBaseUrl(request))) + } + + const redirectUri = `${getBaseUrl(request)}/api/auth/gitlab/callback` + + const tokenResponse = await fetch('https://gitlab.com/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }), + }) + + if (!tokenResponse.ok) { + return NextResponse.redirect(new URL('/?error=token_exchange_failed', getBaseUrl(request))) + } + + const tokenJson = (await tokenResponse.json()) as { access_token?: string; error?: string } + const access_token = tokenJson.access_token + + if (!access_token) { + return NextResponse.redirect(new URL('/?error=token_exchange_failed', getBaseUrl(request))) + } + + const response = NextResponse.redirect( + new URL('/dashboard/repositories?connected=gitlab', getBaseUrl(request)) + ) + + response.cookies.set('gitlab_access_token', access_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 30, + }) + response.cookies.set('gitlab_oauth_state', '', { path: '/', maxAge: 0 }) + + return response + } catch { + return NextResponse.redirect(new URL('/?error=oauth_callback_failed', getBaseUrl(request))) + } +} diff --git a/app/api/auth/gitlab/login/route.ts b/app/api/auth/gitlab/login/route.ts new file mode 100644 index 0000000..af602d4 --- /dev/null +++ b/app/api/auth/gitlab/login/route.ts @@ -0,0 +1,36 @@ +import crypto from 'node:crypto' +import { NextRequest, NextResponse } from 'next/server' + +function getBaseUrl(request: NextRequest) { + return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin +} + +export async function GET(request: NextRequest) { + const clientId = process.env.GITLAB_CLIENT_ID + + if (!clientId) { + return NextResponse.redirect(new URL('/?error=gitlab_oauth_not_configured', getBaseUrl(request))) + } + + const state = crypto.randomUUID() + const redirectUri = `${getBaseUrl(request)}/api/auth/gitlab/callback` + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'read_user read_repository', + state, + }) + + const response = NextResponse.redirect(`https://gitlab.com/oauth/authorize?${params.toString()}`) + response.cookies.set('gitlab_oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, + }) + + return response +} diff --git a/app/api/bitbucket/repos/route.ts b/app/api/bitbucket/repos/route.ts new file mode 100644 index 0000000..c3f83eb --- /dev/null +++ b/app/api/bitbucket/repos/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +interface BitbucketRepo { + slug: string + name: string + full_name: string + description: string + links: { html: { href: string } } + mainbranch: { name: string } | null + is_private: boolean + language: string + size: number + updated_on: string +} + +interface BitbucketResponse { + values: BitbucketRepo[] + next?: string +} + +export async function GET() { + const cookieStore = await cookies() + const accessToken = cookieStore.get('bitbucket_access_token')?.value + + if (!accessToken) { + return NextResponse.json({ error: 'Not authenticated with Bitbucket' }, { status: 401 }) + } + + try { + const repos: BitbucketRepo[] = [] + let url: string | undefined = 'https://api.bitbucket.org/2.0/repositories?role=member&pagelen=100&sort=-updated_on' + + while (url && repos.length < 300) { + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': 'CodeVault', + }, + cache: 'no-store', + }) + + if (!res.ok) { + if (res.status === 401) { + return NextResponse.json({ error: 'Bitbucket token expired — please reconnect' }, { status: 401 }) + } + break + } + + const data = (await res.json()) as BitbucketResponse + repos.push(...data.values) + url = data.next + } + + const normalized = repos.map((r) => ({ + id: r.full_name, + name: r.name, + full_name: r.full_name, + description: r.description || null, + url: r.links.html.href, + language: r.language || null, + stars: 0, + default_branch: r.mainbranch?.name ?? 'main', + private: r.is_private, + platform: 'bitbucket', + })) + + return NextResponse.json(normalized) + } catch { + return NextResponse.json({ error: 'Failed to fetch Bitbucket repositories' }, { status: 500 }) + } +} diff --git a/app/api/gitlab/repos/route.ts b/app/api/gitlab/repos/route.ts new file mode 100644 index 0000000..55c260f --- /dev/null +++ b/app/api/gitlab/repos/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +interface GitLabProject { + id: number + name: string + path_with_namespace: string + description: string | null + web_url: string + default_branch: string | null + visibility: string + star_count: number + forks_count: number + last_activity_at: string + topics: string[] + programming_language?: string +} + +export async function GET() { + const cookieStore = await cookies() + const accessToken = cookieStore.get('gitlab_access_token')?.value + + if (!accessToken) { + return NextResponse.json({ error: 'Not authenticated with GitLab' }, { status: 401 }) + } + + try { + const repos: GitLabProject[] = [] + let page = 1 + const perPage = 100 + + while (repos.length < 300) { + const res = await fetch( + `https://gitlab.com/api/v4/projects?membership=true&per_page=${perPage}&page=${page}&order_by=last_activity_at&sort=desc`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': 'CodeVault', + }, + cache: 'no-store', + } + ) + + if (!res.ok) { + if (res.status === 401) { + return NextResponse.json({ error: 'GitLab token expired — please reconnect' }, { status: 401 }) + } + break + } + + const data = (await res.json()) as GitLabProject[] + if (data.length === 0) break + repos.push(...data) + + const totalPages = Number(res.headers.get('X-Total-Pages') ?? 1) + if (page >= totalPages) break + page++ + } + + const normalized = repos.map((p) => ({ + id: p.id, + name: p.name, + full_name: p.path_with_namespace, + description: p.description, + url: p.web_url, + language: null, + stars: p.star_count, + default_branch: p.default_branch ?? 'main', + private: p.visibility === 'private', + platform: 'gitlab', + })) + + return NextResponse.json(normalized) + } catch { + return NextResponse.json({ error: 'Failed to fetch GitLab repositories' }, { status: 500 }) + } +} diff --git a/app/page.tsx b/app/page.tsx index a5ea4ea..b3aeb56 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,19 @@ import Link from 'next/link' import { Button } from '@/components/ui/button' -import { Github, Sparkles, Code2, Layers, ArrowRight, AlertCircle, Shield, Zap, GitBranch, Check } from 'lucide-react' +import { + Github, + Sparkles, + Code2, + Layers, + ArrowRight, + AlertCircle, + Shield, + Zap, + GitBranch, + Check, + GitMerge, + Globe, +} from 'lucide-react' const ERROR_MESSAGES: Record = { auth_required: 'You must sign in to access the dashboard.', @@ -12,6 +25,22 @@ const ERROR_MESSAGES: Record = { oauth_callback_failed: 'Something went wrong finishing sign-in. Try again.', } +function GitLabIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +function BitbucketIcon({ className }: { className?: string }) { + return ( + + + + ) +} + export default async function HomePage({ searchParams }: { searchParams: Promise<{ error?: string }> }) { const { error } = await searchParams const errorMessage = error ? ERROR_MESSAGES[error] ?? 'An unexpected error occurred.' : null @@ -51,13 +80,13 @@ export default async function HomePage({ searchParams }: { searchParams: Promise - {/* Hero Section */}
+ {/* Hero Section */}
- {/* Background gradient effects */}
+
@@ -70,30 +99,45 @@ export default async function HomePage({ searchParams }: { searchParams: Promise · Cross-repo blueprint engine powered by Claude AI
- +

Ship what you've already built

- +

- CodeVault scans your GitHub repositories and discovers products hiding across your codebase. - Get AI-generated blueprints showing exactly what you can ship — and what's missing. + CodeVault scans your repositories across GitHub, GitLab, and Bitbucket — then discovers + products hiding in your codebase. Get AI blueprints showing exactly what you can ship.

-
- - + + +
+
@@ -116,8 +160,43 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
- {/* Metrics bar */} + {/* Platform support strip */}
+
+

+ Connect repositories from any platform +

+
+
+
+ +
+ GitHub +
+
+
+ +
+ GitLab +
+
+
+ +
+ Bitbucket +
+
+
+ +
+ Public URL +
+
+
+
+ + {/* Metrics bar */} +
@@ -140,7 +219,7 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
- {/* Feature Cards */} + {/* How it works */}

How CodeVault works

@@ -153,11 +232,12 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
1
- +
-

Connect repos

+

Connect any platform

- Link your GitHub repositories with read-only OAuth. We scan file structures — never store your source code. + Link repos from GitHub, GitLab, or Bitbucket with read-only OAuth — or paste any public repo URL. + We scan file structures, never store your source code.

@@ -168,7 +248,8 @@ export default async function HomePage({ searchParams }: { searchParams: Promise

AI analyzes patterns

- Claude AI examines every file across all your repos — identifying components, APIs, utilities, and how they fit together. + Claude AI examines every file across all your repos — identifying components, APIs, utilities, + and how they fit together.

@@ -179,7 +260,8 @@ export default async function HomePage({ searchParams }: { searchParams: Promise

Get blueprints

- See exactly what apps you can ship. Each blueprint shows files to reuse, what's missing, effort estimates, and a build plan. + See exactly what apps you can ship. Each blueprint shows files to reuse, what's missing, + effort estimates, and a full build plan.

@@ -194,12 +276,13 @@ export default async function HomePage({ searchParams }: { searchParams: Promise Stop rebuilding what you already have

- Most teams have 60-80% of their next product scattered across existing repos. CodeVault finds those hidden assets and shows you the shortest path to shipping. + Most teams have 60-80% of their next product scattered across existing repos on multiple + platforms. CodeVault finds those hidden assets and shows you the shortest path to shipping.

    {[ 'Identifies reusable components across all your repos', - 'Calculates exactly what percentage of code you can reuse', + 'Works across GitHub, GitLab, and Bitbucket simultaneously', 'Shows the specific files missing to complete each app', 'Generates scaffold code and build plans for missing pieces', ].map((item) => ( @@ -225,6 +308,7 @@ export default async function HomePage({ searchParams }: { searchParams: Promise "name": "Customer Dashboard", "reuse_percentage": 78, "complexity": "moderate", + "sources": ["github", "gitlab"], "existing_files": [ "components/Chart.tsx", "lib/api-client.ts", @@ -244,6 +328,68 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
+ {/* Platform deep-dive */} +
+
+
+

Recover repos from anywhere

+

+ Wherever your code lives, CodeVault can find it and bring it into a single unified analysis. +

+
+ +
+
+
+ +
+

GitHub

+

+ Sign in with GitHub OAuth to instantly list all your public and private repositories. + Supports organizations and personal accounts. +

+
+ {['Public repos', 'Private repos', 'Organizations'].map((tag) => ( + {tag} + ))} +
+
+ +
+
+ +
+

GitLab

+

+ Connect GitLab to import projects from gitlab.com. Works with personal projects, + groups, and subgroups. +

+
+ {['Personal projects', 'Groups', 'Subgroups'].map((tag) => ( + {tag} + ))} +
+
+ +
+
+ +
+

Bitbucket

+

+ Link your Bitbucket workspace to pull in repositories from both personal accounts + and teams. +

+
+ {['Personal repos', 'Teams', 'Workspaces'].map((tag) => ( + {tag} + ))} +
+
+
+
+
+ {/* CTA Section */}
@@ -252,15 +398,30 @@ export default async function HomePage({ searchParams }: { searchParams: Promise Ready to discover what you can ship?

- Connect your GitHub and get your first blueprint in under a minute. Free to use, no credit card required. + Connect any of your platforms and get your first blueprint in under a minute. + Free to start, no credit card required.

- +
+ + + +
@@ -268,14 +429,38 @@ export default async function HomePage({ searchParams }: { searchParams: Promise {/* Footer */}