13 KiB
13 KiB
Week 1: 認證系統實作指南
目標
建立完整的用戶認證系統,包含註冊、登入、登出功能,並設置 Protected Routes。
Day 1-2: Supabase Auth 設置
1. 安裝依賴
npm install @supabase/supabase-js @supabase/auth-helpers-nextjs
npm install @supabase/auth-ui-react @supabase/auth-ui-shared
2. 建立 Supabase Client
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
3. 建立 Server Client
// lib/supabase/server.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'
export function createClient() {
const cookieStore = cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
cookieStore.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
cookieStore.set({ name, value: '', ...options })
},
},
}
)
}
Day 2-3: 認證頁面實作
1. 註冊頁面
// app/(auth)/register/page.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import Link from 'next/link'
export default function RegisterPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (password !== confirmPassword) {
setError('密碼不匹配')
return
}
if (password.length < 6) {
setError('密碼至少需要 6 個字符')
return
}
setLoading(true)
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/dashboard')
}
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="w-[400px]">
<CardHeader>
<CardTitle>建立帳號</CardTitle>
<CardDescription>開始你的美劇學習之旅</CardDescription>
</CardHeader>
<form onSubmit={handleRegister}>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">密碼</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">確認密碼</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={loading}
/>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-2">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? '註冊中...' : '註冊'}
</Button>
<div className="text-sm text-center">
已有帳號?
<Link href="/login" className="text-primary hover:underline ml-1">
立即登入
</Link>
</div>
</CardFooter>
</form>
</Card>
</div>
)
}
2. 登入頁面
// app/(auth)/login/page.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import Link from 'next/link'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/dashboard')
router.refresh()
}
const handleGoogleLogin = async () => {
setError(null)
setLoading(true)
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
if (error) {
setError(error.message)
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="w-[400px]">
<CardHeader>
<CardTitle>歡迎回來</CardTitle>
<CardDescription>登入繼續你的學習</CardDescription>
</CardHeader>
<form onSubmit={handleLogin}>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">密碼</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
/>
</div>
<div className="text-right">
<Link href="/forgot-password" className="text-sm text-primary hover:underline">
忘記密碼?
</Link>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-2">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? '登入中...' : '登入'}
</Button>
<Separator className="my-2" />
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleGoogleLogin}
disabled={loading}
>
使用 Google 登入
</Button>
<div className="text-sm text-center mt-2">
還沒有帳號?
<Link href="/register" className="text-primary hover:underline ml-1">
立即註冊
</Link>
</div>
</CardFooter>
</form>
</Card>
</div>
)
}
Day 4: Protected Routes 設置
1. Middleware 設置
// middleware.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({
name,
value,
...options,
})
response = NextResponse.next({
request: {
headers: request.headers,
},
})
response.cookies.set({
name,
value,
...options,
})
},
remove(name: string, options: CookieOptions) {
request.cookies.set({
name,
value: '',
...options,
})
response = NextResponse.next({
request: {
headers: request.headers,
},
})
response.cookies.set({
name,
value: '',
...options,
})
},
},
}
)
const { data: { user } } = await supabase.auth.getUser()
// 保護需要認證的路由
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
// 已登入用戶訪問認證頁面時重定向
if (user && (
request.nextUrl.pathname === '/login' ||
request.nextUrl.pathname === '/register'
)) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return response
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
2. Auth Context Provider
// components/providers/auth-provider.tsx
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import { User } from '@supabase/supabase-js'
import { createClient } from '@/lib/supabase/client'
type AuthContextType = {
user: User | null
loading: boolean
}
const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
})
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const supabase = createClient()
useEffect(() => {
const getUser = async () => {
const { data: { user } } = await supabase.auth.getUser()
setUser(user)
setLoading(false)
}
getUser()
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setUser(session?.user ?? null)
}
)
return () => subscription.unsubscribe()
}, [supabase])
return (
<AuthContext.Provider value={{ user, loading }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
Day 5: 測試與優化
測試清單
- 註冊流程完整測試
- Email 驗證流程
- 登入/登出功能
- Google OAuth 登入
- Protected routes 重定向
- Session 持久化
- 錯誤處理
效能優化
- 實施 loading states
- 錯誤邊界處理
- 表單驗證優化
- Session 快取策略
安全檢查
- CSRF 保護
- Rate limiting
- 密碼強度要求
- SQL injection 防護
部署檢查清單
Vercel 環境變數
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
Supabase 設置
- Email 模板自定義
- OAuth 提供者配置
- URL 配置(Site URL, Redirect URLs)
- RLS 政策啟用
常見問題
1. OAuth 回調錯誤
確保在 Supabase Dashboard 中設置正確的 Redirect URLs:
- 開發環境:
http://localhost:3000/auth/callback - 生產環境:
https://your-domain.com/auth/callback
2. Session 不持久
檢查 middleware 和 cookie 設置是否正確。
3. RLS 政策錯誤
確保所有表都有適當的 RLS 政策,並且已啟用。
下一步
完成認證系統後,進入 Week 2: AI 功能實作