diff --git a/.env.example b/.env.example index 31c938d..682c6f1 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,8 @@ DATABASE_URL="postgresql://ht44@localhost:5432/winninghunter?schema=public" AUTH_COOKIE_NAME=wh_session AUTH_SESSION_DAYS=30 ADMIN_EMAILS="admin@winninghunter.local" + +# Google OAuth (https://console.cloud.google.com/apis/credentials) +# Authorized redirect URI: {NEXT_PUBLIC_APP_URL}/api/auth/google/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= diff --git a/.env.production.example b/.env.production.example index 8aef295..a921946 100644 --- a/.env.production.example +++ b/.env.production.example @@ -4,6 +4,11 @@ AUTH_COOKIE_NAME=wh_session AUTH_SESSION_DAYS=30 ADMIN_EMAILS="admin@winninghunter.local" +# Google OAuth (https://console.cloud.google.com/apis/credentials) +# Authorized redirect URI: {NEXT_PUBLIC_APP_URL}/api/auth/google/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + # For docker-compose POSTGRES_PASSWORD="change-this-long-random-password" PORT=3000 diff --git a/.gitignore b/.gitignore index fe5e967..c98f2c5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules .DS_Store next-env.d.ts /prisma/dev.db +tsconfig.tsbuildinfo MEMORY.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2e27ac7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +**Keep this block, including in commits.** It is part of the project's agent setup, maintained by `next dev` for every agent that works here. If it appears as an uncommitted change, that is intentional — commit it as-is. Do not remove it to clean up a diff; it will be regenerated. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/docker-compose.yml b/docker-compose.yml index dd8d96f..c56e4cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,8 @@ services: AUTH_COOKIE_NAME: wh_session AUTH_SESSION_DAYS: 30 ADMIN_EMAILS: admin@winninghunter.local + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} ports: - "${PORT:-3000}:3000" command: sh -c "npx prisma db push && npm run start" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e022084..30330b6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -86,6 +86,7 @@ model User { id String @id @default(cuid()) email String @unique passwordHash String? + googleId String? @unique name String? avatarUrl String? role UserRole @default(USER) diff --git a/src/app/api/auth/google/callback/route.ts b/src/app/api/auth/google/callback/route.ts new file mode 100644 index 0000000..39c3293 --- /dev/null +++ b/src/app/api/auth/google/callback/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { UserRole } from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { createSession } from "@/lib/auth/session"; +import { isConfiguredAdminEmail } from "@/lib/admin-emails"; +import { exchangeGoogleCode, fetchGoogleProfile, GOOGLE_STATE_COOKIE } from "@/lib/auth/google"; + +function redirectToLogin(error: string) { + return NextResponse.redirect( + new URL(`/login?error=${encodeURIComponent(error)}`, process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000") + ); +} + +export async function GET(req: Request) { + const url = new URL(req.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const oauthError = url.searchParams.get("error"); + + if (oauthError) return redirectToLogin(oauthError); + + const cookieStore = await cookies(); + const expectedState = cookieStore.get(GOOGLE_STATE_COOKIE)?.value; + cookieStore.delete(GOOGLE_STATE_COOKIE); + + if (!code || !state || !expectedState || state !== expectedState) { + return redirectToLogin("GOOGLE_STATE_MISMATCH"); + } + + let tokens; + try { + tokens = await exchangeGoogleCode(code); + } catch (err) { + console.error("[google/callback] token exchange failed:", err); + return redirectToLogin("GOOGLE_AUTH_FAILED"); + } + + let profile; + try { + profile = await fetchGoogleProfile(tokens.access_token); + } catch (err) { + console.error("[google/callback] profile fetch failed:", err); + return redirectToLogin("GOOGLE_AUTH_FAILED"); + } + + if (!profile.sub) return redirectToLogin("GOOGLE_AUTH_FAILED"); + + const email = (profile.email || "").trim().toLowerCase(); + if (!email) return redirectToLogin("GOOGLE_EMAIL_REQUIRED"); + + const isAdmin = isConfiguredAdminEmail(email); + const plan = await prisma.plan.findUnique({ where: { code: isAdmin ? "PREMIUM" : "FREE" } }); + + let user = await prisma.user.findUnique({ where: { googleId: profile.sub } }); + if (!user) { + user = await prisma.user.findUnique({ where: { email } }); + } + + if (!user) { + if (!plan) return redirectToLogin("PLAN_NOT_SEEDED"); + user = await prisma.user.create({ + data: { + email, + googleId: profile.sub, + name: profile.name || email.split("@")[0], + avatarUrl: profile.picture || null, + locale: profile.locale || "tr", + role: isAdmin ? UserRole.ADMIN : UserRole.USER, + subscription: { create: { planId: plan.id, status: "ACTIVE" } } + } + }); + } else { + user = await prisma.user.update({ + where: { id: user.id }, + data: { + googleId: user.googleId || profile.sub, + name: user.name || profile.name || null, + avatarUrl: user.avatarUrl || profile.picture || null, + lastLoginAt: new Date() + } + }); + } + + await createSession(user.id); + return NextResponse.redirect( + new URL("/dashboard/ads", process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000") + ); +} diff --git a/src/app/api/auth/google/start/route.ts b/src/app/api/auth/google/start/route.ts new file mode 100644 index 0000000..bb4aa66 --- /dev/null +++ b/src/app/api/auth/google/start/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { randomToken } from "@/lib/auth/crypto"; +import { googleAuthUrl, googleConfig, GOOGLE_STATE_COOKIE } from "@/lib/auth/google"; + +export async function GET() { + const config = googleConfig(); + if (!config) { + return NextResponse.redirect( + new URL("/login?error=GOOGLE_NOT_CONFIGURED", process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000") + ); + } + + const state = randomToken(16); + const cookieStore = await cookies(); + cookieStore.set(GOOGLE_STATE_COOKIE, state, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 600 + }); + + const authUrl = googleAuthUrl(state); + if (!authUrl) { + return NextResponse.redirect( + new URL("/login?error=GOOGLE_NOT_CONFIGURED", process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000") + ); + } + + return NextResponse.redirect(authUrl); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index a4793b9..533bcf8 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,6 +2,17 @@ import { useState } from "react"; +function GoogleIcon() { + return ( + + ); +} + export default function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -66,8 +77,19 @@ export default function LoginPage() { /> {error &&
{error}
} + + + + Google ile devam et + - diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 27e5272..a2545b4 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -2,6 +2,17 @@ import { useState } from "react"; +function GoogleIcon() { + return ( + + ); +} + export default function RegisterPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -54,8 +65,19 @@ export default function RegisterPage() { /> {error &&
{error}
} + + + + Google ile devam et + - diff --git a/src/lib/auth/google.ts b/src/lib/auth/google.ts new file mode 100644 index 0000000..04a000c --- /dev/null +++ b/src/lib/auth/google.ts @@ -0,0 +1,83 @@ +const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"; + +export const GOOGLE_STATE_COOKIE = "wh_google_oauth_state"; + +export function googleConfig() { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + if (!clientId || !clientSecret) return null; + return { clientId, clientSecret }; +} + +export function googleRedirectUri() { + const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + return `${appUrl.replace(/\/$/, "")}/api/auth/google/callback`; +} + +export function googleAuthUrl(state: string) { + const config = googleConfig(); + if (!config) return null; + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: googleRedirectUri(), + response_type: "code", + scope: "openid email profile", + access_type: "online", + prompt: "select_account", + state + }); + return `${GOOGLE_AUTH_URL}?${params.toString()}`; +} + +export type GoogleTokenResponse = { + access_token: string; + token_type: string; + expires_in: number; + id_token?: string; + scope?: string; +}; + +export async function exchangeGoogleCode(code: string) { + const config = googleConfig(); + if (!config) throw new Error("GOOGLE_NOT_CONFIGURED"); + + const body = new URLSearchParams({ + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: googleRedirectUri(), + grant_type: "authorization_code" + }); + + const res = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`GOOGLE_TOKEN_EXCHANGE_FAILED: ${res.status} ${text.slice(0, 200)}`); + } + + return (await res.json()) as GoogleTokenResponse; +} + +export type GoogleProfile = { + sub: string; + email?: string; + email_verified?: boolean; + name?: string; + picture?: string; + locale?: string; +}; + +export async function fetchGoogleProfile(accessToken: string) { + const res = await fetch(GOOGLE_USERINFO_URL, { + headers: { authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) throw new Error(`GOOGLE_PROFILE_FAILED: ${res.status}`); + return (await res.json()) as GoogleProfile; +}