Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:46:51 +08:00
commit 26a675f3a7
15 changed files with 1450 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
'use server';
import { createServerClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
/**
* Sign out the current user
*/
export async function logout() {
const supabase = await createServerClient();
await supabase.auth.signOut();
revalidatePath('/', 'layout');
redirect('/');
}
/**
* Sign in with email and password
*/
export async function signInWithPassword(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const supabase = await createServerClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return { error: error.message };
}
revalidatePath('/', 'layout');
redirect('/dashboard');
}
/**
* Sign up with email and password
*/
export async function signUpWithPassword(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const supabase = await createServerClient();
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
});
if (error) {
return { error: error.message };
}
return { success: 'Check your email to confirm your account' };
}
/**
* Send magic link for passwordless login
*/
export async function signInWithMagicLink(formData: FormData) {
const email = formData.get('email') as string;
const supabase = await createServerClient();
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
});
if (error) {
return { error: error.message };
}
return { success: 'Check your email for the magic link' };
}
/**
* Request password reset
*/
export async function resetPassword(formData: FormData) {
const email = formData.get('email') as string;
const supabase = await createServerClient();
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`,
});
if (error) {
return { error: error.message };
}
return { success: 'Check your email for the password reset link' };
}

View File

@@ -0,0 +1,29 @@
import { createServerClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const next = searchParams.get('next') ?? '/dashboard';
if (code) {
const supabase = await createServerClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
const forwardedHost = request.headers.get('x-forwarded-host');
const isLocalEnv = process.env.NODE_ENV === 'development';
if (isLocalEnv) {
return NextResponse.redirect(`${origin}${next}`);
} else if (forwardedHost) {
return NextResponse.redirect(`https://${forwardedHost}${next}`);
} else {
return NextResponse.redirect(`${origin}${next}`);
}
}
}
// Return the user to an error page with instructions
return NextResponse.redirect(`${origin}/auth/auth-code-error`);
}

View File

@@ -0,0 +1,67 @@
import { createServerClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
/**
* Get the current authenticated user
* Returns null if not authenticated
*/
export async function getCurrentUser() {
const supabase = await createServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
return user;
}
/**
* Get the current session
* Returns null if not authenticated
*/
export async function getSession() {
const supabase = await createServerClient();
const {
data: { session },
} = await supabase.auth.getSession();
return session;
}
/**
* Require authentication - redirects to login if not authenticated
* Use in Server Components and Server Actions that require auth
*/
export async function requireAuth() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
return user;
}
/**
* Check if user has specific role
* Assumes roles are stored in user metadata or a profiles table
*/
export async function hasRole(role: string): Promise<boolean> {
const user = await getCurrentUser();
if (!user) return false;
// Check user metadata for role
const userRole = user.user_metadata?.role;
return userRole === role;
}
/**
* Require specific role - redirects to unauthorized page if role not met
*/
export async function requireRole(role: string) {
const user = await requireAuth();
if (!hasRole(role)) {
redirect('/unauthorized');
}
return user;
}

View File

@@ -0,0 +1,67 @@
import { requireAuth } from '@/lib/auth/utils';
import { logout } from '@/app/actions/auth';
export default async function DashboardPage() {
const user = await requireAuth();
return (
<div className="min-h-screen p-8">
<div className="mx-auto max-w-4xl">
<div className="mb-8 flex items-center justify-between">
<h1 className="text-3xl font-bold">Dashboard</h1>
<form action={logout}>
<button
type="submit"
className="rounded-md border border-gray-300 px-4 py-2 hover:bg-gray-50"
>
Sign out
</button>
</form>
</div>
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-xl font-semibold">User Information</h2>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="text-base">{user.email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">User ID</dt>
<dd className="font-mono text-sm">{user.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">
Email Verified
</dt>
<dd className="text-base">
{user.email_confirmed_at ? 'Yes' : 'No'}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">
Last Sign In
</dt>
<dd className="text-base">
{user.last_sign_in_at
? new Date(user.last_sign_in_at).toLocaleString()
: 'N/A'}
</dd>
</div>
</dl>
</div>
<div className="mt-6 rounded-lg bg-blue-50 p-4">
<p className="text-sm text-blue-900">
This is a protected page. You can only access it when authenticated.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { signInWithPassword, signInWithMagicLink } from '@/app/actions/auth';
import { createServerClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export default async function LoginPage({
searchParams,
}: {
searchParams: { redirect?: string; error?: string };
}) {
// Check if user is already logged in
const supabase = await createServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (user) {
redirect(searchParams.redirect || '/dashboard');
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 rounded-lg border p-8">
<div>
<h2 className="text-center text-3xl font-bold">Sign in</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Access your account
</p>
</div>
{searchParams.error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{searchParams.error}</p>
</div>
)}
{/* Email/Password Form */}
<form action={signInWithPassword} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<button
type="submit"
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Sign in with email
</button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500">Or</span>
</div>
</div>
{/* Magic Link Form */}
<form action={signInWithMagicLink} className="space-y-4">
<div>
<label htmlFor="magic-email" className="block text-sm font-medium">
Email for magic link
</label>
<input
id="magic-email"
name="email"
type="email"
autoComplete="email"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<button
type="submit"
className="w-full rounded-md border border-gray-300 bg-white px-4 py-2 hover:bg-gray-50"
>
Send magic link
</button>
</form>
{/* OAuth Providers - Uncomment to enable */}
{/*
<div className="space-y-2">
<button
onClick={() => signInWithOAuth('google')}
className="w-full rounded-md border border-gray-300 bg-white px-4 py-2 hover:bg-gray-50"
>
Continue with Google
</button>
<button
onClick={() => signInWithOAuth('github')}
className="w-full rounded-md border border-gray-300 bg-white px-4 py-2 hover:bg-gray-50"
>
Continue with GitHub
</button>
</div>
*/}
<div className="text-center text-sm">
<a href="/signup" className="text-blue-600 hover:underline">
Don't have an account? Sign up
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { type NextRequest } from 'next/server';
import { updateSession } from '@/lib/supabase/middleware';
export async function middleware(request: NextRequest) {
const { supabaseResponse, user } = await updateSession(request);
// If accessing protected route without authentication, redirect to login
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
const redirectUrl = new URL('/login', request.url);
redirectUrl.searchParams.set('redirect', request.nextUrl.pathname);
return Response.redirect(redirectUrl);
}
return supabaseResponse;
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};

View File

@@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}

View File

@@ -0,0 +1,38 @@
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
request.cookies.set(name, value);
});
supabaseResponse = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) => {
supabaseResponse.cookies.set(name, value, options);
});
},
},
}
);
// Refresh session if expired - required for Server Components
const {
data: { user },
} = await supabase.auth.getUser();
return { supabaseResponse, user };
}

View File

@@ -0,0 +1,29 @@
import { createServerClient as createSSRServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createServerClient() {
const cookieStore = await cookies();
return createSSRServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
);
}