Initial commit
This commit is contained in:
98
skills/supabase-auth-ssr-setup/assets/auth-actions.ts
Normal file
98
skills/supabase-auth-ssr-setup/assets/auth-actions.ts
Normal 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' };
|
||||
}
|
||||
29
skills/supabase-auth-ssr-setup/assets/auth-callback-route.ts
Normal file
29
skills/supabase-auth-ssr-setup/assets/auth-callback-route.ts
Normal 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`);
|
||||
}
|
||||
67
skills/supabase-auth-ssr-setup/assets/auth-utils.ts
Normal file
67
skills/supabase-auth-ssr-setup/assets/auth-utils.ts
Normal 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;
|
||||
}
|
||||
67
skills/supabase-auth-ssr-setup/assets/dashboard-page.tsx
Normal file
67
skills/supabase-auth-ssr-setup/assets/dashboard-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
skills/supabase-auth-ssr-setup/assets/login-page.tsx
Normal file
134
skills/supabase-auth-ssr-setup/assets/login-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
skills/supabase-auth-ssr-setup/assets/middleware.ts
Normal file
28
skills/supabase-auth-ssr-setup/assets/middleware.ts
Normal 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)$).*)',
|
||||
],
|
||||
};
|
||||
8
skills/supabase-auth-ssr-setup/assets/supabase-client.ts
Normal file
8
skills/supabase-auth-ssr-setup/assets/supabase-client.ts
Normal 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!
|
||||
);
|
||||
}
|
||||
38
skills/supabase-auth-ssr-setup/assets/supabase-middleware.ts
Normal file
38
skills/supabase-auth-ssr-setup/assets/supabase-middleware.ts
Normal 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 };
|
||||
}
|
||||
29
skills/supabase-auth-ssr-setup/assets/supabase-server.ts
Normal file
29
skills/supabase-auth-ssr-setup/assets/supabase-server.ts
Normal 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.
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user