Update Auth

This commit is contained in:
MartinBraquet
2025-07-27 00:08:26 +02:00
parent dba94f7617
commit e970713dcc
11 changed files with 673 additions and 63 deletions

47
middleware.ts Normal file
View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
const publicPaths = ['/', '/login', '/signup', '/api/auth/signin', '/api/auth/signout'];
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const token = await getToken({ req });
// Allow access to public paths and static files
if (
publicPaths.some(path => path === pathname) ||
pathname.startsWith('/_next') ||
pathname.startsWith('/favicon.ico') ||
pathname.startsWith('/public/')
) {
// If user is logged in and tries to access auth pages, redirect to dashboard
if (token && (pathname.startsWith('/login') || pathname.startsWith('/signup'))) {
return NextResponse.redirect(new URL('/dashboard', req.url));
}
return NextResponse.next();
}
// If no token and not a public path, redirect to login
if (!token) {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
'/((?!api|_next/static|_next/image|favicon.ico|public).*)',
],
};

View File

@@ -0,0 +1,57 @@
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT,
"email" TEXT,
"email_verified" DATETIME,
"image" TEXT,
"password" TEXT
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" DATETIME NOT NULL,
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -7,8 +7,8 @@ generator client {
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
provider = "sqlite"
url = "file:./dev.db"
}
model Account {
@@ -17,12 +17,12 @@ model Account {
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -1,6 +1,28 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
import NextAuth from 'next-auth';
import { authConfig } from '@/auth';
const handler = NextAuth(authOptions);
// Initialize NextAuth with the configuration
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
...authConfig,
// Enable debug logs in development
debug: process.env.NODE_ENV === 'development',
// Ensure cookies are secure in production
cookies: {
sessionToken: {
name: `__Secure-next-auth.session-token`,
options: {
httpOnly: true,
sameSite: 'lax', // CSRF protection
path: '/',
secure: process.env.NODE_ENV === 'production',
},
},
},
});
export { handler as GET, handler as POST };
export { GET, POST, auth, signIn, signOut };

View File

@@ -1,6 +1,7 @@
'use client';
import { signOut, useSession } from 'next-auth/react';
import { signOut } from '@/app/api/auth/[...nextauth]/route';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
@@ -22,8 +23,9 @@ export default function DashboardPage() {
);
}
if (!session) {
return null; // Will be redirected by the useEffect
if (status === 'unauthenticated') {
router.push('/login');
return null;
}
return (
@@ -34,9 +36,12 @@ export default function DashboardPage() {
<div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900">Dashboard</h1>
</div>
<div className="flex items-center">
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700">
Welcome, {session.user?.name || 'User'}!
</span>
<button
onClick={() => signOut({ callbackUrl: '/' })}
onClick={() => signOut({ redirect: true, callbackUrl: '/' })}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Sign out
@@ -48,10 +53,106 @@ export default function DashboardPage() {
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="border-4 border-dashed border-gray-200 rounded-lg h-96 p-6 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Welcome, {session.user?.name || 'User'}!</h2>
<p className="text-gray-600">You are now logged in.</p>
<p className="text-gray-600 mt-2">Email: {session.user?.email}</p>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{/* User Info Card */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0 bg-indigo-500 rounded-md p-3">
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Account</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">
{session.user?.name || 'User'}
</div>
</dd>
<dd className="text-sm text-gray-500 truncate">
{session.user?.email}
</dd>
</dl>
</div>
</div>
</div>
</div>
{/* Stats Card 1 */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0 bg-green-500 rounded-md p-3">
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Projects</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">3</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
{/* Stats Card 2 */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0 bg-yellow-500 rounded-md p-3">
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Last Login</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">
Just now
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Recent Activity */}
<div className="mt-8">
<h3 className="text-lg font-medium text-gray-900 mb-4">Recent Activity</h3>
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
<li className="px-6 py-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center">
<svg className="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-900">Successfully logged in</p>
<p className="text-sm text-gray-500">A few seconds ago</p>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</main>

View File

@@ -1,34 +1,128 @@
'use client';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import Link from 'next/link';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') || '/dashboard';
const errorParam = searchParams.get('error');
const registered = searchParams.get('registered');
// Handle URL parameters and errors
useEffect(() => {
const newSearchParams = new URLSearchParams(searchParams);
// Handle error parameter
if (errorParam) {
let errorMessage = 'An error occurred during login';
// Map common error codes to user-friendly messages
if (errorParam === 'CredentialsSignin') {
errorMessage = 'Invalid email or password';
} else if (errorParam === 'OAuthAccountNotLinked') {
errorMessage = 'This email is already associated with another account';
} else if (errorParam === 'OAuthCallbackError') {
errorMessage = 'An error occurred during social sign in';
} else if (errorParam === 'AccessDenied') {
errorMessage = 'You do not have permission to sign in';
} else if (errorParam === 'Verification') {
errorMessage = 'Account not verified. Please check your email.';
}
setError(errorMessage);
// Clean up the URL
newSearchParams.delete('error');
router.replace(`/login?${newSearchParams.toString()}`);
}
// Show success message if user was just registered
if (registered) {
setSuccess('Registration successful! Please sign in with your credentials.');
newSearchParams.delete('registered');
router.replace(`/login?${newSearchParams.toString()}`);
}
}, [errorParam, registered, router, searchParams]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
try {
const result = await signIn('credentials', {
redirect: false,
email,
password,
});
if (result?.error) {
setError(result.error);
return;
// Validate inputs
if (!email || !password) {
throw new Error('Email and password are required');
}
router.push('/dashboard');
// Basic email validation
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new Error('Please enter a valid email address');
}
// Attempt to sign in
const result = await signIn('credentials', {
redirect: false,
email: email.trim(),
password,
callbackUrl,
});
// Handle the result
if (result?.error) {
// Handle specific error messages
let errorMessage = 'Invalid email or password';
if (result.error === 'CredentialsSignin') {
errorMessage = 'Invalid email or password';
} else if (result.error === 'AccessDenied') {
errorMessage = 'You do not have permission to sign in';
} else if (result.error === 'Configuration') {
errorMessage = 'Server configuration error';
} else if (result.error === 'Verification') {
errorMessage = 'Account not verified. Please check your email.';
} else if (result.error === 'OAuthSignin') {
errorMessage = 'Error in OAuth sign in. Please try again.';
} else if (result.error === 'OAuthCallback') {
errorMessage = 'Error in OAuth callback. Please try again.';
} else if (result.error === 'OAuthCreateAccount') {
errorMessage = 'Error creating OAuth account. Please try again.';
} else if (result.error === 'EmailCreateAccount') {
errorMessage = 'Error creating account. Please try again.';
} else if (result.error === 'Callback') {
errorMessage = 'Error in authentication callback. Please try again.';
} else if (result.error === 'OAuthAccountNotLinked') {
errorMessage = 'This email is already associated with another account';
}
throw new Error(errorMessage);
}
// If we got here, sign in was successful
if (result?.url) {
// Ensure we're using the correct callback URL
const url = new URL(result.url);
const callbackUrlParam = url.searchParams.get('callbackUrl');
const finalUrl = callbackUrlParam || callbackUrl;
// Force a full page reload to ensure all session data is loaded
window.location.href = finalUrl;
} else {
// Fallback in case result.url is not available
window.location.href = callbackUrl;
}
} catch (err) {
setError('An error occurred. Please try again.');
setError(err instanceof Error ? err.message : 'An error occurred');
setIsLoading(false);
}
};
@@ -40,30 +134,72 @@ export default function LoginPage() {
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<a
href="/signup"
Don't have an account?{' '}
<Link
href={`/signup${callbackUrl ? `?callbackUrl=${encodeURIComponent(callbackUrl)}` : ''}`}
className="font-medium text-indigo-600 hover:text-indigo-500"
>
create a new account
</a>
Sign up
</Link>
</p>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
<div>
<button
onClick={() => signIn('google', { callbackUrl })}
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<span className="sr-only">Sign in with Google</span>
<svg className="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 24 24">
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
</svg>
</button>
</div>
<div>
<button
onClick={() => signIn('github', { callbackUrl })}
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<span className="sr-only">Sign in with GitHub</span>
<svg className="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.699 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C17.14 18.25 20 14.42 20 10.017 20 4.484 15.522 0 10 0z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4">
{success && (
<div className="bg-green-50 border-l-4 border-green-500 p-4 mb-4">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
<svg className="h-5 w-5 text-green-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-green-700">{success}</p>
</div>
</div>
</div>
)}
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">

View File

@@ -1,7 +1,9 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { signIn } from 'next-auth/react';
export default function SignupPage() {
const [name, setName] = useState('');
@@ -11,28 +13,81 @@ export default function SignupPage() {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') || '/dashboard';
// Handle any error from the URL (e.g., from auth callback)
useEffect(() => {
const errorParam = searchParams.get('error');
if (errorParam) {
// Remove the error from the URL
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.delete('error');
router.replace(`/signup?${newSearchParams.toString()}`);
// Set appropriate error message
let errorMessage = 'An error occurred during signup';
if (errorParam === 'OAuthAccountNotLinked') {
errorMessage = 'This email is already associated with another account';
} else if (errorParam === 'OAuthCallbackError') {
errorMessage = 'An error occurred during social sign in';
}
setError(errorMessage);
}
}, [router, searchParams]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
// Basic validation
if (!name || !email || !password) {
throw new Error('All fields are required');
}
if (password.length < 8) {
throw new Error('Password must be at least 8 characters long');
}
// Create user in the database
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, email, password }),
body: JSON.stringify({
name,
email,
password // The password will be hashed in the API route
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Something went wrong');
throw new Error(data.message || 'Something went wrong during signup');
}
// Redirect to login page after successful signup
router.push('/login');
// Automatically sign in the user after successful signup
const signInResult = await signIn('credentials', {
redirect: false,
email,
password,
callbackUrl,
});
if (signInResult?.error) {
// If sign in fails, redirect to login page with a success message
router.push(`/login?registered=true&callbackUrl=${encodeURIComponent(callbackUrl)}`);
} else if (signInResult?.url) {
// If sign in is successful, redirect to the callback URL or dashboard
router.push(signInResult.url);
} else {
// Fallback redirect
router.push(callbackUrl);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setIsLoading(false);
@@ -47,14 +102,51 @@ export default function SignupPage() {
Create a new account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<a
href="/login"
Already have an account?{' '}
<Link
href={`/login${callbackUrl ? `?callbackUrl=${encodeURIComponent(callbackUrl)}` : ''}`}
className="font-medium text-indigo-600 hover:text-indigo-500"
>
sign in to your existing account
</a>
Sign in
</Link>
</p>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
<div>
<button
onClick={() => signIn('google', { callbackUrl })}
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<span className="sr-only">Sign in with Google</span>
<svg className="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 24 24">
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
</svg>
</button>
</div>
<div>
<button
onClick={() => signIn('github', { callbackUrl })}
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<span className="sr-only">Sign in with GitHub</span>
<svg className="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.699 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C17.14 18.25 20 14.42 20 10.017 20 4.484 15.522 0 10 0z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4">

67
src/auth.config.ts Normal file
View File

@@ -0,0 +1,67 @@
import type { NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { prisma } from "./lib/prisma";
import { compare } from "bcryptjs";
export default {
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("Email and password are required");
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.password) {
throw new Error("Invalid email or password");
}
const isValid = await compare(
credentials.password as string,
user.password
);
if (!isValid) {
throw new Error("Invalid email or password");
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
callbacks: {
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.sub!;
}
return session;
},
async jwt({ token, user }) {
if (user) {
token.sub = user.id;
}
return token;
},
},
session: {
strategy: "jwt",
},
pages: {
signIn: "/login",
error: "/login",
},
secret: process.env.NEXTAUTH_SECRET,
} satisfies NextAuthConfig;

83
src/auth.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { NextAuthConfig } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { prisma } from './lib/prisma';
import { compare } from 'bcryptjs';
export const authConfig = {
pages: {
signIn: '/login',
error: '/login',
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
if (isOnDashboard) {
return isLoggedIn;
} else if (isLoggedIn) {
return Response.redirect(new URL('/dashboard', nextUrl));
}
return true;
},
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.email = user.email;
token.name = user.name;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.name = token.name;
session.user.email = token.email as string;
}
return session;
},
},
providers: [
Credentials({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error('Email and password are required');
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.password) {
throw new Error('Invalid email or password');
}
const isValid = await compare(
credentials.password as string,
user.password
);
if (!isValid) {
throw new Error('Invalid email or password');
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
session: {
strategy: 'jwt',
},
secret: process.env.NEXTAUTH_SECRET,
trustHost: true,
} satisfies NextAuthConfig;

View File

@@ -1,14 +1,16 @@
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from '@/generated/prisma';
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma = global.prisma || new PrismaClient();
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma;
globalForPrisma.prisma = prisma;
}
async function connectDB() {