dramaling-vocab-learning/docs/03_development/implementation/week1-auth.md

508 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)