Phase 3: Authentication¶
Mục tiêu¶
Implement authentication flow với Supabase Auth cho SSR application.
Thời gian ước tính: 3 giờ
Step 1: Auth Configuration¶
1.1 Configure Supabase Auth (Dashboard)¶
- Vào Authentication → Providers
- Enable Email:
- Confirm email: ON (for production)
- Secure email change: ON
- Enable Google (optional):
- Create OAuth credentials in Google Cloud Console
- Add Client ID và Client Secret
1.2 Configure redirect URLs¶
Vào Authentication → URL Configuration:
Site URL: http://localhost:3000 (dev) / https://your-domain.com (prod)
Redirect URLs:
- http://localhost:3000/auth/callback
- https://your-domain.com/auth/callback
Step 2: Create Auth Pages¶
2.1 Login Page¶
// src/app/(auth)/login/page.tsx
import { LoginForm } from '@/components/auth/login-form';
import Link from 'next/link';
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow-md">
<h1 className="text-2xl font-bold text-center mb-6">
Login to TaskFlow
</h1>
<LoginForm />
<p className="mt-4 text-center text-gray-600">
Don't have an account?{' '}
<Link href="/register" className="text-blue-600 hover:underline">
Register
</Link>
</p>
</div>
</div>
);
}
// src/components/auth/login-form.tsx
'use client';
import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const supabase = createClient();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
router.push('/dashboard');
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleGoogleLogin = async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) setError(error.message);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
<div className="relative my-4">
<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="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<button
type="button"
onClick={handleGoogleLogin}
className="w-full border border-gray-300 py-2 rounded-lg hover:bg-gray-50 flex items-center justify-center gap-2"
>
<GoogleIcon />
Google
</button>
</form>
);
}
function GoogleIcon() {
return (
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
);
}
2.2 Register Page¶
// src/app/(auth)/register/page.tsx
import { RegisterForm } from '@/components/auth/register-form';
import Link from 'next/link';
export default function RegisterPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow-md">
<h1 className="text-2xl font-bold text-center mb-6">
Create Account
</h1>
<RegisterForm />
<p className="mt-4 text-center text-gray-600">
Already have an account?{' '}
<Link href="/login" className="text-blue-600 hover:underline">
Login
</Link>
</p>
</div>
</div>
);
}
// src/components/auth/register-form.tsx
'use client';
import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
export function RegisterForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [fullName, setFullName] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const router = useRouter();
const supabase = createClient();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: fullName,
},
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) throw error;
setSuccess(true);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (success) {
return (
<div className="text-center">
<div className="text-green-600 text-lg mb-4">
Check your email!
</div>
<p className="text-gray-600">
We've sent you a confirmation link. Please check your email to complete registration.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Full Name
</label>
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="John Doe"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="At least 8 characters"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
);
}
2.3 Auth Callback¶
// src/app/auth/callback/route.ts
import { createClient } 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 createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
return NextResponse.redirect(`${origin}${next}`);
}
}
// Return to login with error
return NextResponse.redirect(`${origin}/login?error=auth_failed`);
}
Step 3: Protected Routes¶
3.1 Update Middleware¶
// src/middleware.ts
import { type NextRequest, NextResponse } from 'next/server';
import { updateSession } from '@/lib/supabase/middleware';
const publicPaths = ['/', '/login', '/register', '/auth/callback', '/auth/confirm'];
export async function middleware(request: NextRequest) {
const response = await updateSession(request);
// Check if path is protected
const isPublicPath = publicPaths.some(path =>
request.nextUrl.pathname === path ||
request.nextUrl.pathname.startsWith('/auth/')
);
if (!isPublicPath) {
// Check auth status
const supabaseResponse = response.headers.get('x-supabase-auth');
// If no session, redirect to login
// (This is a simplified check - the actual session check happens in updateSession)
}
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
3.2 Create Auth Check Component¶
// src/components/auth/auth-guard.tsx
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
export async function AuthGuard({ children }: { children: React.ReactNode }) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/login');
}
return <>{children}</>;
}
3.3 Dashboard Layout with Auth¶
// src/app/(dashboard)/layout.tsx
import { AuthGuard } from '@/components/auth/auth-guard';
import { Sidebar } from '@/components/layout/sidebar';
import { Header } from '@/components/layout/header';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthGuard>
<div className="flex h-screen bg-gray-100">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
</AuthGuard>
);
}
Step 4: Auth Context and Hooks¶
4.1 Auth Context (for client components)¶
// src/components/providers/auth-provider.tsx
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { User, Session } from '@supabase/supabase-js';
interface AuthContextType {
user: User | null;
session: Session | null;
loading: boolean;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
user: null,
session: null,
loading: true,
signOut: async () => {},
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const supabase = createClient();
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
});
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
}
);
return () => subscription.unsubscribe();
}, [supabase]);
const signOut = async () => {
await supabase.auth.signOut();
window.location.href = '/login';
};
return (
<AuthContext.Provider value={{ user, session, loading, signOut }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
4.2 Add Provider to Root Layout¶
// src/app/layout.tsx
import { AuthProvider } from '@/components/providers/auth-provider';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}
Step 5: User Menu Component¶
// src/components/layout/user-menu.tsx
'use client';
import { useAuth } from '@/components/providers/auth-provider';
import { useState } from 'react';
export function UserMenu() {
const { user, signOut } = useAuth();
const [open, setOpen] = useState(false);
if (!user) return null;
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100"
>
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white">
{user.email?.[0].toUpperCase()}
</div>
<span className="text-sm font-medium">{user.email}</span>
</button>
{open && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border">
<div className="p-3 border-b">
<p className="text-sm font-medium">{user.user_metadata?.full_name}</p>
<p className="text-xs text-gray-500">{user.email}</p>
</div>
<div className="p-1">
<button
onClick={signOut}
className="w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded"
>
Sign Out
</button>
</div>
</div>
)}
</div>
);
}
Verification Checklist¶
- [ ] Email/password registration working
- [ ] Email confirmation flow working
- [ ] Login with email/password working
- [ ] OAuth login with Google (if configured)
- [ ] Protected routes redirecting unauthenticated users
- [ ] Auth state persisting across page refreshes
- [ ] Sign out working
- [ ] Profile auto-created on signup
Common Issues¶
Issue: Session not persisting¶
Solution: Ensure middleware is properly updating cookies and the correct cookie configuration is used.
Issue: Redirect loop¶
Solution: Check that login/register pages are in the publicPaths list in middleware.
Issue: Google OAuth error¶
Solution: Verify redirect URLs in both Supabase and Google Cloud Console match exactly.
Next Phase¶
Chuyển sang Phase 4: Core Features để xây dựng các tính năng chính.