508 lines
13 KiB
Markdown
508 lines
13 KiB
Markdown
# Week 1: 認證系統實作指南
|
||
|
||
## 目標
|
||
建立完整的用戶認證系統,包含註冊、登入、登出功能,並設置 Protected Routes。
|
||
|
||
## Day 1-2: Supabase Auth 設置
|
||
|
||
### 1. 安裝依賴
|
||
```bash
|
||
npm install @supabase/supabase-js @supabase/auth-helpers-nextjs
|
||
npm install @supabase/auth-ui-react @supabase/auth-ui-shared
|
||
```
|
||
|
||
### 2. 建立 Supabase Client
|
||
```typescript
|
||
// 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
|
||
```typescript
|
||
// 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. 註冊頁面
|
||
```typescript
|
||
// 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. 登入頁面
|
||
```typescript
|
||
// 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 設置
|
||
```typescript
|
||
// 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
|
||
```typescript
|
||
// 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 持久化
|
||
- [ ] 錯誤處理
|
||
|
||
### 效能優化
|
||
1. 實施 loading states
|
||
2. 錯誤邊界處理
|
||
3. 表單驗證優化
|
||
4. Session 快取策略
|
||
|
||
### 安全檢查
|
||
1. CSRF 保護
|
||
2. Rate limiting
|
||
3. 密碼強度要求
|
||
4. SQL injection 防護
|
||
|
||
## 部署檢查清單
|
||
|
||
### Vercel 環境變數
|
||
```env
|
||
NEXT_PUBLIC_SUPABASE_URL=
|
||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||
SUPABASE_SERVICE_ROLE_KEY=
|
||
```
|
||
|
||
### Supabase 設置
|
||
1. Email 模板自定義
|
||
2. OAuth 提供者配置
|
||
3. URL 配置(Site URL, Redirect URLs)
|
||
4. 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 功能實作](./week2-ai.md) |