From 4ba7521a38df997539060efeeb9b02ce719ddafd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Fri, 10 Apr 2026 22:23:13 +0300 Subject: [PATCH] feat(auth): implement OAuth login with Google and GitHub, add Prisma adapter for user management --- app/(auth)/login/login-form.tsx | 257 ++++++++++++++++++++++++++++++++ app/(auth)/login/page.tsx | 168 ++------------------- bun.lock | 7 +- lib/auth.ts | 47 +++++- package.json | 1 + 5 files changed, 321 insertions(+), 159 deletions(-) create mode 100644 app/(auth)/login/login-form.tsx diff --git a/app/(auth)/login/login-form.tsx b/app/(auth)/login/login-form.tsx new file mode 100644 index 0000000..4cc424b --- /dev/null +++ b/app/(auth)/login/login-form.tsx @@ -0,0 +1,257 @@ +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { signIn } from 'next-auth/react'; + +function getSafeCallbackUrl(value: string | null): string { + if (!value) return '/dashboard'; + try { + const baseOrigin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin; + const parsed = new URL(value, baseOrigin); + if (parsed.origin !== baseOrigin) return '/dashboard'; + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return '/dashboard'; + } +} + +const ERROR_MESSAGES: Record = { + RegistrationClosed: 'Sign-up is currently invite-only. Contact an administrator.', + // Generic message — avoid confirming whether a credentials account exists for this email + OAuthAccountNotLinked: 'Sign-in failed. Please try a different method or contact support.', + OAuthCallbackError: 'OAuth sign-in failed. Please try again.', + OAuthEmailNotVerified: 'Your OAuth account email is not verified. Please verify it with your provider and try again.', + Default: 'Something went wrong. Please try again.', +}; + +interface LoginFormInnerProps { + googleEnabled: boolean; + githubEnabled: boolean; +} + +function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isLoading, setIsLoading] = useState(false); + const [oauthLoading, setOauthLoading] = useState(null); + const [error, setError] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showSuccess, setShowSuccess] = useState(false); + const callbackUrl = getSafeCallbackUrl(searchParams.get('callbackUrl')); + + useEffect(() => { + if (searchParams.get('registered') === 'true') { + setShowSuccess(true); + } + const errorCode = searchParams.get('error'); + if (errorCode) { + setError(ERROR_MESSAGES[errorCode] ?? ERROR_MESSAGES.Default); + } + }, [searchParams]); + + const handleEmailLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + + try { + const result = await signIn('credentials', { + email, + password, + redirect: false, + callbackUrl, + }); + + if (result?.error) { + setError('Invalid email or password'); + return; + } + + const destination = getSafeCallbackUrl(result?.url || callbackUrl); + router.push(destination); + router.refresh(); + } catch { + setError('Something went wrong. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleOAuthLogin = async (provider: string) => { + setOauthLoading(provider); + setError(''); + await signIn(provider, { callbackUrl }); + }; + + const hasOAuth = googleEnabled || githubEnabled; + const anyLoading = isLoading || oauthLoading !== null; + + return ( + + + Welcome back + Sign in to your account to continue + + + {showSuccess && ( +
+ Account created successfully! Please sign in. +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* OAuth Buttons */} + {hasOAuth && ( +
+ {googleEnabled && ( + + )} + {githubEnabled && ( + + )} +
+ )} + + {/* Divider */} + {hasOAuth && ( +
+
+ +
+
+ or continue with email +
+
+ )} + + {/* Email Form */} +
+
+ + { + setEmail(e.target.value); + setError(''); + }} + required + disabled={anyLoading} + /> +
+
+ + { + setPassword(e.target.value); + setError(''); + }} + required + disabled={anyLoading} + /> +
+ + +
+ +

+ Don't have an account?{' '} + + Sign up + +

+
+
+ ); +} + +export function LoginFormSkeleton() { + return ( + + + Welcome back + Sign in to your account to continue + + +
+
+
+ + + ); +} + +export function LoginForm({ googleEnabled, githubEnabled }: LoginFormInnerProps) { + return ( + }> + + + ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 9235251..1aac5e9 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,161 +1,16 @@ -'use client'; - -import { useState, useEffect, Suspense } from 'react'; import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { Video, Loader2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { signIn } from 'next-auth/react'; - -function getSafeCallbackUrl(value: string | null): string { - if (!value) return '/dashboard'; - try { - const baseOrigin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin; - const parsed = new URL(value, baseOrigin); - if (parsed.origin !== baseOrigin) return '/dashboard'; - return `${parsed.pathname}${parsed.search}${parsed.hash}`; - } catch { - return '/dashboard'; - } -} - -function LoginForm() { - const router = useRouter(); - const searchParams = useSearchParams(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [showSuccess, setShowSuccess] = useState(false); - const callbackUrl = getSafeCallbackUrl(searchParams.get('callbackUrl')); - - useEffect(() => { - if (searchParams.get('registered') === 'true') { - setShowSuccess(true); - } - }, [searchParams]); - - const handleEmailLogin = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - setError(''); - - try { - const result = await signIn('credentials', { - email, - password, - redirect: false, - callbackUrl, - }); - - if (result?.error) { - setError('Invalid email or password'); - return; - } - - const destination = getSafeCallbackUrl(result?.url || callbackUrl); - router.push(destination); - router.refresh(); - } catch { - setError('Something went wrong. Please try again.'); - } finally { - setIsLoading(false); - } - }; - - return ( - - - Welcome back - - Sign in to your account to continue - - - - {showSuccess && ( -
- Account created successfully! Please sign in. -
- )} - - {/* Email Form */} -
-
- - { - setEmail(e.target.value); - setError(''); - }} - required - disabled={isLoading} - /> -
-
- - { - setPassword(e.target.value); - setError(''); - }} - required - disabled={isLoading} - /> -
- - {error && ( -
- {error} -
- )} - - -
- -

- Don't have an account?{' '} - - Sign up - -

-
-
- ); -} - -function LoginFormSkeleton() { - return ( - - - Welcome back - - Sign in to your account to continue - - - -
-
-
- - - ); -} +import { Video } from 'lucide-react'; +import { LoginForm, LoginFormSkeleton } from './login-form'; +import { Suspense } from 'react'; export default function LoginPage() { + const googleEnabled = Boolean( + process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET, + ); + const githubEnabled = Boolean( + process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET, + ); + return (
@@ -166,7 +21,7 @@ export default function LoginPage() { }> - +

@@ -176,3 +31,4 @@ export default function LoginPage() {

); } + diff --git a/bun.lock b/bun.lock index 6f6b6e0..0f92e90 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "openframe", "dependencies": { + "@auth/prisma-adapter": "^2.11.1", "@aws-sdk/client-s3": "^3.985.0", "@prisma/adapter-pg": "^7.3.0", "@prisma/client": "^7.3.0", @@ -57,7 +58,9 @@ "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], - "@auth/core": ["@auth/core@0.41.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ=="], + "@auth/core": ["@auth/core@0.41.1", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw=="], + + "@auth/prisma-adapter": ["@auth/prisma-adapter@2.11.1", "", { "dependencies": { "@auth/core": "0.41.1" }, "peerDependencies": { "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" } }, "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug=="], "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], @@ -1971,6 +1974,8 @@ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "next-auth/@auth/core": ["@auth/core@0.41.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], diff --git a/lib/auth.ts b/lib/auth.ts index bac6d5a..48ce57a 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,17 +1,22 @@ import NextAuth from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; +import Google from 'next-auth/providers/google'; +import GitHub from 'next-auth/providers/github'; +import { PrismaAdapter } from '@auth/prisma-adapter'; import bcrypt from 'bcryptjs'; import { db } from '@/lib/db'; import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; import { hasBillingAccess } from '@/lib/billing'; +import { isInviteCodeRequired } from '@/lib/feature-flags'; // Dummy hash for timing-safe comparison when user doesn't exist // This prevents user enumeration via timing attacks const DUMMY_HASH = '$2a$12$000000000000000000000uGG3k3xK2CVTxXrT7VW2sGd1XrY6Ky'; export const { handlers, signIn, signOut, auth } = NextAuth({ - // Note: We don't use PrismaAdapter with Credentials + JWT strategy - // The adapter is for OAuth providers that need to store accounts/sessions in DB + // PrismaAdapter handles OAuth account linking and user creation in DB. + // JWT strategy is still used for sessions (no DB sessions table needed). + adapter: PrismaAdapter(db), providers: [ Credentials({ name: 'credentials', @@ -50,6 +55,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ }; }, }), + ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET + ? [Google({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET })] + : []), + ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET + ? [{ + ...GitHub({ clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET }), + // GitHub sends iss=https://github.com/login/oauth in callbacks (RFC 9207). + // Auth.js v5 beta defaults to "https://authjs.dev" for OAuth providers, causing + // a mismatch. Setting the correct issuer here fixes the CallbackRouteError. + issuer: 'https://github.com/login/oauth', + }] + : []), ], session: { strategy: 'jwt', @@ -60,6 +77,32 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ signOut: '/signout', }, callbacks: { + async signIn({ account, profile }) { + // Credentials sign-in is handled by the authorize() function above + if (account?.provider === 'credentials') return true; + + // Reject OAuth sign-ins where the provider email is not verified. + // Google always sets email_verified: true. GitHub does not guarantee it. + if (profile && profile.email_verified === false) { + return '/login?error=OAuthEmailNotVerified'; + } + + // OAuth sign-in: allow existing OAuth accounts regardless of invite setting + if (account?.providerAccountId && account?.provider) { + const existingAccount = await db.account.findUnique({ + where: { provider_providerAccountId: { provider: account.provider, providerAccountId: account.providerAccountId } }, + select: { id: true }, + }); + if (existingAccount) return true; + } + + // New OAuth user: block when invite-only mode is active + if (isInviteCodeRequired()) { + return '/login?error=RegistrationClosed'; + } + + return true; + }, async session({ session, token }) { if (token.sub && session.user) { session.user.id = token.sub; diff --git a/package.json b/package.json index 1ef0126..283ac86 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "bunny:cleanup-orphans": "bun run scripts/bunny-orphan-cleanup.ts" }, "dependencies": { + "@auth/prisma-adapter": "^2.11.1", "@aws-sdk/client-s3": "^3.985.0", "@prisma/adapter-pg": "^7.3.0", "@prisma/client": "^7.3.0",