歡迎來到 Day 22!看到這篇文章的同時,表示中秋連假也到此為止了,但如果你是個狠人請了下週的三天假,那恭喜你!假期才剛開始呢:D OK! 幹話結束,我們昨天在 Supabase 上開啟了驗證系統以及使用者練習紀錄的資料表,雖然目前兩者都是完全沒有使用到的,除了主控台頁面上空空的欄位之外我們什麼也沒看到,今天就要利用昨天的鋪墊做整合,在我們專案中加入 Supabse 後端 Auth 的邏輯。
你可能會感到有點奇怪,「我們明明用 Supabase 已經好一段時間了,邏輯也都寫在後端,甚至還整理出個 lib/supabase.ts
檔案抽分邏輯,你怎麼還會說我們要做 Supabase 的後端整合?」。這個問題問得很好,我們必須先釐清這一點!直接說結論,在整合 Supabase Auth 時,我們需要兩種不同職責的 Supabase Client。
一個是我們已有的、使用最高權限 SERVICE_KEY
的「後端管理員 Client」,專門用來執行像 RAG 搜尋這樣的系統級任務。另一個則是我們今天要建立的、使用公開 ANON_KEY
的「前端使用者 Client」,專門用來處理登入、註冊等與使用者身份相關的操作。
理解了這個區別後,我們將採用 Supabase 最新的 @supabase/ssr
工具庫,以最現代、最安全的方式來打造我們的登入與註冊頁面。
@supabase/ssr
工具庫。首先,我們需要安裝最新的 @supabase/ssr
套件,它將取代已被棄用的 auth-helpers
,如果你之前沒有跟著教學,那你得記得還要額外安裝@supabase/supabase-js
。
npm install @supabase/ssr
為了讓架構清晰,我們在 lib 下建立一個 supabase 資料夾,並分別管理前後端的 Client。
將你現有的 lib/supabase.ts
移動並重命名為 lib/supabase/server.ts
,記得透過IDE或手動的方式去修正引入的地方。它的原本內容則保持不變,因為它作為後端管理員的角色是正確的,我們只要新增幾個部分就好。
// app/lib/supabase/server.ts
import { createClient } from '@supabase/supabase-js';
import { createServerClient } from '@supabase/ssr'// 新增引入
import { cookies } from 'next/headers'; // 新增引入
// ...原本的rag相關東西
// 創建一個用於認證的 Supabase client
export async function createAuthClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_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.
}
},
},
}
);
}
現在,我們使用 @supabase/ssr
來建立一個專門給前端元件使用的 Client。
// app/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
// 建立一個專門給瀏覽器環境 (Client Components) 使用的 Supabase Client
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);
}
裡面出現了一個我們之前沒有建立的環境變數,請你回到專案中的.env.local
檔案,並加入這個NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
,值的部分從專案的Project Settings
-> API Keys
進入管理 Key 頁面,再次抱怨一下,介面更新但 AI 資料沒跟上時真的會造成一堆鬼打牆,我的 AI 助理死命給我錯的資訊,請按照下圖找到指定的 Key。
![]() |
---|
圖1 :PUBLISHABLE_KEY位置 |
另外在我們忘記之前,也記得把這個東西存到你Vercel專案的環境變數,免得部署時又要再抱怨一次。
Middleware 是 Next.js 中一個強大的功能,它會在請求完成前執行。對於 Supabase Auth 來說,它的職責是自動刷新即將過期的 Token,確保使用者在瀏覽網站時的登入狀態始終保持最新,並保護需要登入才能訪問的頁面。
官方推薦將邏輯拆分為兩個檔案,以保持程式碼的整潔和可維護性。
這個檔案包含了與 Supabase 互動、刷新 Session、以及處理重新導向的核心邏輯。
請先建立一個新的檔案在這個檔案路徑: app/utils/supabase/middleware.ts
。
// app/utils/supabase/middleware.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
const supabase = createServerClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
// If the cookie is set, update the request cookies and response cookies
request.cookies.set({ name, value, ...options })
response = NextResponse.next({
request: {
headers: request.headers,
},
})
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
// If the cookie is removed, update the request cookies and response cookies
request.cookies.set({ name, value: '', ...options })
response = NextResponse.next({
request: {
headers: request.headers,
},
})
response.cookies.set({ name, value: '', ...options })
},
},
}
)
// 刷新使用者 session,這至關重要!
// 它能確保即使 token 過期,也能在下一次請求時自動刷新。
// 官方特別強調,不要移除這一行!
const { data: { user } } = await supabase.auth.getUser()
// 如果使用者未登入,且訪問的不是登入相關頁面,則將其重新導向至登入頁
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth') // 排除 /auth/callback 等路由
) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// 必須回傳這個 response 物件,它包含了更新後的 cookie。
// 如果不這樣做,瀏覽器和伺服器之間的 session 會不同步,導致使用者被意外登出。
return response
}
這個檔案非常簡潔,它的唯一工作就是引入並執行我們剛剛建立的 updateSession
函式。
// middleware.ts
import { updateSession } from '@/app/utils/supabase/middleware'
import { type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
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)
* Feel free to modify this pattern to include more paths.
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
你問到有點看不懂,這很正常,Middleware 裡的邏輯確實比較抽象。讓我把它拆解成白話文:想像一下你的網站是一棟有門禁的大樓。
這位警衛(middleware.ts)不會自己做太多判斷。他的工作手冊上只有一條指令:「所有進來的人(除了送貨員 - matcher 裡排除的靜態檔案),都交給保全中心(updateSession 函式)處理。」
這是真正的核心。當警衛把一個訪客(request)帶進來時,保全中心會做以下幾件事:
基礎設置做好了,來處理登入跟註冊的邏輯吧,為了示範的方便,我大幅度簡化了 UI 並省略了基本的輸入驗證,只是單純整合官方的教學而已,實際上你在做的時候需要加入一些前端認證,我這邊為了示範方便直接把它弄成一個 server頁面處理,請你先新增app/auth
資料夾並在裡面放入以下三個檔案。
// app/auth/action.ts
'use server';
import { createAuthClient } from '@/app/lib/supabase/server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
export async function login(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const supabase = await createAuthClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
const message = '登入失敗,請檢查您的帳號或密碼';
return redirect(`/auth?message=${encodeURIComponent(message)}`);
}
revalidatePath('/', 'layout');
return redirect('/dashboard');
}
export async function signup(formData: FormData) {
const origin = (await headers()).get('origin');
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const supabase = await createAuthClient();
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${origin}/auth/callback`,
},
});
if (error) {
const message = '註冊失敗,該信箱可能已被使用';
return redirect(`/auth?message=${encodeURIComponent(message)}`);
}
revalidatePath('/', 'layout');
const message = '註冊成功!請檢查您的信箱以完成驗證';
return redirect(`/auth?message=${encodeURIComponent(message)}`);
}
另一個則是 UI 的頁面
// app/auth/page.tsx
import { login, signup } from '@/app/auth/action';
import { Bot, Mail, Lock } from 'lucide-react';
export default function AuthPage({
searchParams,
}: {
searchParams: { message: string };
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-blue-900 to-gray-900 flex items-center justify-center p-4 font-sans">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<Bot size={48} className="text-blue-400" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">
AI Interview Pro
</h1>
<p className="text-gray-400">精進技能,成為頂尖工程師</p>
</div>
<div className="bg-gray-800 bg-opacity-75 backdrop-blur-sm rounded-2xl p-8 shadow-2xl border border-gray-700">
<form className="space-y-6">
<div className="space-y-4">
<div>
<label
className="block text-sm font-medium text-gray-300 mb-2"
htmlFor="email"
>
電子郵件
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Mail size={20} />
</span>
<input
id="email"
name="email"
type="email"
className="w-full bg-gray-700 text-white rounded-lg pl-10 pr-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="example@email.com"
required
/>
</div>
</div>
<div>
<label
className="block text-sm font-medium text-gray-300 mb-2"
htmlFor="password"
>
密碼
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<Lock size={20} />
</span>
<input
id="password"
name="password"
type="password"
className="w-full bg-gray-700 text-white rounded-lg pl-10 pr-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="輸入密碼"
required
/>
</div>
</div>
</div>
{searchParams?.message && (
<p className="text-center text-sm text-green-400 p-2 bg-green-900/30 rounded-md">
{searchParams.message}
</p>
)}
<div className="flex flex-col sm:flex-row gap-2">
<button
formAction={login}
className="flex-1 w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-transform transform hover:scale-105 duration-300"
>
登入
</button>
<button
formAction={signup}
className="flex-1 w-full bg-gray-600 hover:bg-gray-700 text-white font-bold py-3 rounded-lg transition-transform transform hover:scale-105 duration-300"
>
註冊
</button>
</div>
</form>
</div>
</div>
</div>
);
}
最後一個檔案則是用來處理點擊信箱收到的認證按鈕後,要跳轉頁面的邏輯。
// app/auth/callback/route.ts
import { createAuthClient } from '@/app/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 createAuthClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
// 驗證成功,重定向到 dashboard
return NextResponse.redirect(`${origin}${next}`);
}
}
// 如果有錯誤,重定向回登入頁面
return NextResponse.redirect(
`${origin}/auth?message=${encodeURIComponent('驗證失敗,請重試')}`
);
}
隨便輸入一組信箱與密碼,按下註冊按鈕,你應該會看到註冊成功的畫面。
![]() |
---|
圖2 :註冊成功畫面 |
若你輸入的真正的信箱,在本地開發環境中點擊按鈕後你應該會發現跳轉失敗,這是因為你需要在 Supabase 的設置中加入頁面跳轉的邏輯處理,我們這邊以本地開發示範。
請到 Authentication → URL Configuration 中,作以下兩個步驟。
![]() |
---|
圖3 :跳轉設定頁面 |
最後一個步驟,修改我們的側邊導覽去增加登出的按鈕。
'use client';
import {
BotMessageSquare,
LayoutDashboard,
MessageSquare,
Code,
History,
LogOut,
} from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { createClient } from '@/app/lib/supabase/client'; // <-- 關鍵:引入前端 Client
const navItems = [
{ href: '/dashboard', label: '主控台', icon: LayoutDashboard },
{ href: '/practice/concept', label: '概念問答', icon: MessageSquare },
{ href: '/practice/code', label: '程式實作', icon: Code },
{ href: '/history', label: '練習紀錄', icon: History },
// { href: '/settings', label: '設定', icon: Settings }, // 設定頁面尚未實作
];
export default function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const isActive = (href: string) => {
if (href === '/dashboard') return pathname === href;
return pathname.startsWith(href);
};
// --- 新增:登出處理函式 ---
const handleLogout = async () => {
// 1. 建立一個前端專用的 Supabase client
const supabase = createClient();
// 2. 呼叫 signOut 方法
const { error } = await supabase.auth.signOut();
if (error) {
console.error('登出時發生錯誤:', error);
alert('登出失敗,請稍後再試。');
} else {
// 3. 登出成功後,將使用者導向回登入頁面
// 並重新整理以確保伺服器狀態更新
router.push('/auth');
router.refresh();
}
};
// --- 結束新增 ---
return (
<nav className="hidden md:flex w-64 bg-[#111827] text-gray-300 flex-col p-4 border-r border-gray-700">
<div className="flex items-center gap-3 mb-8">
<BotMessageSquare size={32} className="text-blue-400" />
<h1 className="text-xl font-bold">AI Interview Pro</h1>
</div>
<ul className="space-y-2">
{navItems.map(({ href, label, icon: Icon }) => (
<li key={label}>
<Link
href={href}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive(href)
? 'bg-blue-600/30 text-white'
: 'hover:bg-gray-700'
}`}
>
<Icon size={20} /> {label}
</Link>
</li>
))}
</ul>
<div className="mt-auto">
{' '}
{/* <--- 新增:將登出按鈕推至底部 */}
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-gray-400 hover:bg-red-900/50 hover:text-red-300"
>
<LogOut size={20} /> 登出
</button>
</div>
</nav>
);
}
點下去後你就會發現已經順利登出了,查看 Devtool -> Applications -> Cookies 你會發現有個sb開頭的key消失,證明你的 seesion 已經結束了。
今天,我們不僅成功地為應用程式裝上了現代化的大門,更重要的是,我們建立了一個清晰、安全的 Supabase Client 架構。
✅ 我們採用了最新的 @supabase/ssr 工具庫,確保驗證機制與 Next.js App Router 完美整合。
✅ 我們清晰地劃分了後端 Server Client 和前端 Browser Client 的職責,讓專案架構更穩固。
✅ 我們打造了功能齊全的登入與註冊 UI 頁面。
✅ 我們成功實作了使用者的註冊、登入與登出邏輯。
使用者現在可以真正登入我們的系統了!下一步,就是要讓他們的練習成果能夠被永久保存。明天 (Day 23),我們將修改後端的 /api/interview/evaluate
API,讓它能夠識別出當前登入的使用者,並在 AI 評估完成後,將這筆寶貴的練習紀錄,連同使用者的 ID,一起安全地寫入 practice_records
資料表中!
今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-22