diff --git a/app/api/auth/github/callback/route.ts b/app/api/auth/github/callback/route.ts index 55e24a1..cd41afc 100644 --- a/app/api/auth/github/callback/route.ts +++ b/app/api/auth/github/callback/route.ts @@ -12,6 +12,25 @@ function getGitHubClientId() { return process.env.GITHUB_CLIENT_ID || process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID } +async function fetchPrimaryEmail(accessToken: string): Promise { + try { + const res = await fetch('https://api.github.com/user/emails', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'RepoFuse', + }, + cache: 'no-store', + }) + if (!res.ok) return null + const emails = (await res.json()) as Array<{ email: string; primary: boolean; verified: boolean }> + const primary = emails.find((e) => e.primary && e.verified) + return primary?.email ?? emails.find((e) => e.verified)?.email ?? null + } catch { + return null + } +} + export async function GET(request: NextRequest) { try { const searchParams = request.nextUrl.searchParams @@ -63,12 +82,16 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL('/?error=token_exchange_failed', getBaseUrl(request))) } - const userResponse = await fetch('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${access_token}`, - Accept: 'application/vnd.github+json', - }, - }) + // Fetch user profile and primary email in parallel + const [userResponse, primaryEmail] = await Promise.all([ + fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${access_token}`, + Accept: 'application/vnd.github+json', + }, + }), + fetchPrimaryEmail(access_token), + ]) if (!userResponse.ok) { return NextResponse.redirect(new URL('/?error=github_user_fetch_failed', getBaseUrl(request))) @@ -79,13 +102,14 @@ export async function GET(request: NextRequest) { try { const sql = getDb() await sql` - INSERT INTO user_auth (github_id, github_username, github_avatar_url, access_token) - VALUES (${githubUser.id}, ${githubUser.login}, ${githubUser.avatar_url}, ${access_token}) + INSERT INTO user_auth (github_id, github_username, github_avatar_url, access_token, email) + VALUES (${githubUser.id}, ${githubUser.login}, ${githubUser.avatar_url}, ${access_token}, ${primaryEmail}) ON CONFLICT (github_id) DO UPDATE SET access_token = ${access_token}, github_username = ${githubUser.login}, github_avatar_url = ${githubUser.avatar_url}, + email = COALESCE(${primaryEmail}, user_auth.email), updated_at = CURRENT_TIMESTAMP ` await upsertSubscription({ github_id: githubUser.id }) @@ -133,4 +157,4 @@ export async function GET(request: NextRequest) { console.error('OAuth callback error:', error) return NextResponse.redirect(new URL('/?error=oauth_callback_failed', getBaseUrl(request))) } -} +} \ No newline at end of file diff --git a/app/api/auth/github/login/route.ts b/app/api/auth/github/login/route.ts index 60dee30..0329c84 100644 --- a/app/api/auth/github/login/route.ts +++ b/app/api/auth/github/login/route.ts @@ -1,48 +1,49 @@ import crypto from 'node:crypto' -import { NextRequest, NextResponse } from 'next/server' -import { sanitizeReturnTo } from '@/lib/auth' + import { NextRequest, NextResponse } from 'next/server' + import { sanitizeReturnTo } from '@/lib/auth' -function getBaseUrl(request: NextRequest) { - return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin -} - -function getGitHubClientId() { - return process.env.GITHUB_CLIENT_ID || process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID -} - -export async function GET(request: NextRequest) { - const clientId = getGitHubClientId() + function getBaseUrl(request: NextRequest) { + return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin + } - if (!clientId) { - return NextResponse.redirect(new URL('/?error=github_oauth_not_configured', getBaseUrl(request))) + function getGitHubClientId() { + return process.env.GITHUB_CLIENT_ID || process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID } - const state = crypto.randomUUID() - const redirectUri = `${getBaseUrl(request)}/api/auth/github/callback` - const returnTo = sanitizeReturnTo(request.nextUrl.searchParams.get('returnTo')) - - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, - scope: 'read:user repo', - state, - }) - - const response = NextResponse.redirect(`https://github.com/login/oauth/authorize?${params.toString()}`) - response.cookies.set('github_oauth_state', state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 60 * 10, - }) - response.cookies.set('github_oauth_return_to', returnTo, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 60 * 10, - }) - - return response -} + export async function GET(request: NextRequest) { + const clientId = getGitHubClientId() + + if (!clientId) { + return NextResponse.redirect(new URL('/?error=github_oauth_not_configured', getBaseUrl(request))) + } + + const state = crypto.randomUUID() + const redirectUri = `${getBaseUrl(request)}/api/auth/github/callback` + const returnTo = sanitizeReturnTo(request.nextUrl.searchParams.get('returnTo')) + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + scope: 'read:user user:email repo', + state, + }) + + const response = NextResponse.redirect(`https://github.com/login/oauth/authorize?${params.toString()}`) + response.cookies.set('github_oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, + }) + response.cookies.set('github_oauth_return_to', returnTo, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, + }) + + return response + } + \ No newline at end of file diff --git a/migrations/009_add_email_to_user_auth.sql b/migrations/009_add_email_to_user_auth.sql new file mode 100644 index 0000000..5202fd6 --- /dev/null +++ b/migrations/009_add_email_to_user_auth.sql @@ -0,0 +1,10 @@ +-- Migration: Add email column to user_auth for marketing list capture + -- Run after 008_*.sql migrations + + ALTER TABLE user_auth + ADD COLUMN IF NOT EXISTS email TEXT; + + -- Index for fast email lookups + CREATE INDEX IF NOT EXISTS idx_user_auth_email ON user_auth (email) + WHERE email IS NOT NULL; + \ No newline at end of file