FE Day 38 登入流程與角色導向
執行需求:CPU 可跑。貫穿專案(Day 31-37)今天進入收尾階段。今天要做四件事:第一,設計登入流程的狀態機(anonymous / authenticated / refreshing / expired),並用 React Context + reducer 管理;第二,串接後端 /auth/login、/auth/refresh,把 access token 放記憶體、refresh token 放 httpOnly cookie;第三,寫一個 RoleGuard 元件,根據角色限制頁面或元件的存取;第四,根據角色自動導向(admin 進後台、customer 進客戶首頁)。今天所有資料都是虛構示範,跟 Web 系列 Day 36 的後端 API 契約完全對齊,無後端在跑時用內建 mock 模式(標示清楚)。
引言
登入流程是前端最容易低估的一塊:看起來只是「收 email 密碼、送請求、跳頁」,但實作起來會碰到 token 怎麼存、怎麼過期、怎麼續期、怎麼區分角色這四個難題。每一個都會決定整個 app 的安全姿勢與使用者體驗。我們今天會把這四個難題一次解決,並把設計理由與取捨講清楚。
貫穿專案的「預約管理系統」是個小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、諮詢工作室,所有資料都是虛構示範)。Web 系列的 FastAPI 後端在 Day 36 完成了認證:argon2id 雜湊、JWT access token(HS256、12 小時)、refresh token(14 天)、admin 與 customer 兩種角色。今天要做的是把這套後端契約接上 Next.js 15 App Router,並用 React 19 的 use 鉤子搭配 Context 寫一個乾淨的登入狀態層。
今天的內容分四段:第一段把共用設定再次說清楚(貫穿 38-44);第二段設計登入狀態機與 reducer;第三段寫登入頁、註冊頁、refresh 攔截器、角色導向;第四段用 Vitest 3.x 寫單元測試驗證 reducer 行為。讀完這篇你會了解:access token 為什麼不該放 localStorage、refresh token rotation 的設計原因、為什麼角色導向要在 layout 層而非 page 層做、怎麼用 React 19 的 use 處理非同步 auth state。
貫穿專案共用設定(Day 31-44 沿用)
從 Day 31 開始的「預約管理系統前端」到今天 Day 38,整個 stack 維持一致設定,避免後續篇章互相矛盾:
- 框架:Next.js 15(App Router、Server Components 預設)、React 19.x、TypeScript 5.9
- 樣式:Tailwind CSS 4.x、CSS variables 主題、Heroicons
- 後端:FastAPI 預約管理系統(Web 系列 Day 35-44 定義的 REST API)
- 認證:JWT access token(12 小時,存記憶體)、refresh token(14 天,httpOnly cookie)
- 角色:admin(管理者)、customer(客戶);單一使用者同一時間只能有一種角色
- 路由:客戶端
/、/services、/bookings、/account;後台/admin、/admin/bookings、/admin/notifications - 時間:後端回傳 ISO 8601 with UTC,前端以
Intl.DateTimeFormat顯示使用者當地時間 - Mock 模式:
NEXT_PUBLIC_API_MODE=mock時走lib/mock/*.tsfixture,不打真實 API;正式模式live - 設計系統:Day 32 建立的
components/ui/(Button、Input、Card、Dialog、Tabs);CSS variables 控制主題色
這份設定從 Day 31 到 Day 44 不變;任何想擴充的東西(例如加上 staff 角色)都會另開章節並在 manifest 標明。今天的登入流程完全遵循這份設定。
原理解念:登入狀態機與 token 儲存
登入狀態的設計比登入本身重要。一個完整的狀態機包含四個狀態:anonymous(未登入)、authenticated(已登入)、refreshing(正在換新 token)、expired(refresh 失敗、被強制登出)。這四個狀態決定每個頁面該怎麼渲染:anonymous 顯示登入連結、authenticated 顯示使用者選單、refreshing 顯示靜默重試(使用者無感)、expired 顯示「請重新登入」提示並清掉所有 local state。少了 refreshing 這層狀態,access token 過期的那一刻使用者會突然被踢回登入頁,體驗非常差。
token 儲存位置有四種選擇,安全性由高到低:
- httpOnly cookie:瀏覽器 JavaScript 完全碰不到,XSS 也偷不走;但 CSRF 風險需用 SameSite 防護。
- 記憶體(React state / Context):重新整理就消失,需要 refresh 機制補回來;沒有任何持久化風險。
- sessionStorage:分頁關閉就消失,比 localStorage 安全但仍有 XSS 風險。
- localStorage:最不安全但最方便,任何 XSS 都能讀走,現代 app 應避免。
我們選「access token 放記憶體、refresh token 放 httpOnly cookie」這個業界主流組合:access token 12 小時過期,但每次 app 啟動時用 refresh token 換新;refresh token 14 天過期,過期就必須重新登入。這樣即使 XSS 偷到 access token,最壞情況是 12 小時內被冒用,refresh token 因為在 httpOnly cookie 內、JavaScript 碰不到所以安全。
角色導向的設計要放在 layout 層而非 page 層。原因是 page 是「內容」、layout 是「框架」:使用者打開 /admin/bookings 時,如果 page 端才檢查角色,會有一瞬間的「無內容閃爍」;若 layout 端先檢查並重導向,使用者看到的會是乾淨的「未授權」頁或直接跳轉。我們用 Next.js 15 的 middleware.ts 在 edge 層做第一層檢查(讀 cookie 判斷有沒有 token)、用 React Server Component layout 做第二層檢查(讀記憶體中的角色)、用 client component 做第三層檢查(互動元件內隱藏不該看到的按鈕)。三層防護避免「忘記檢查」的單點漏洞。
完整實作:AuthProvider、登入頁、refresh 攔截器
先寫 AuthProvider。這個元件在 root layout 包住整個 app,用 React 19 的 use 鉤子搭配 useReducer 管理狀態。文章內以 React.createElement 風格描述,部署時 Next.js 會編譯成標準 React element tree:
// app/providers/AuthProvider.tsx
'use client';
import { createElement as h, createContext, use, useCallback, useEffect, useReducer, useState } from 'react';
const STATUS_LOADING = 'loading';
const STATUS_ANONYMOUS = 'anonymous';
const STATUS_AUTHENTICATED = 'authenticated';
const STATUS_EXPIRED = 'expired';
// 五個 action:BOOTSTRAP(啟動時注入 user)、LOGIN(登入成功)
// REFRESH(access token 換新)、LOGOUT(主動登出)、EXPIRE(refresh 失敗)
function reducer(state, action) {
switch (action.type) {
case 'BOOTSTRAP':
if (!action.payload) return { status: STATUS_ANONYMOUS, user: null, accessToken: null };
return { status: STATUS_AUTHENTICATED, user: action.payload.user, accessToken: action.payload.accessToken };
case 'LOGIN':
return { status: STATUS_AUTHENTICATED, user: action.payload.user, accessToken: action.payload.accessToken };
case 'REFRESH':
return { ...state, accessToken: action.payload.accessToken };
case 'LOGOUT':
case 'EXPIRE':
return { status: action.type === 'EXPIRE' ? STATUS_EXPIRED : STATUS_ANONYMOUS, user: null, accessToken: null };
default:
return state;
}
}
const initialState = { status: STATUS_LOADING, user: null, accessToken: null };
// AuthContext 由 createContext 建立,後續用 Provider 把 state 注入
export const AuthContext = createContext(null);
export function AuthProvider(props) {
const [state, dispatch] = useReducer(reducer, initialState);
const refresh = useCallback(async () => {
const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
if (!res.ok) {
dispatch({ type: 'EXPIRE' });
return;
}
const data = await res.json();
dispatch({ type: 'REFRESH', payload: { accessToken: data.access_token } });
}, []);
const value = { state, dispatch, refresh };
// 渲染 AuthContext.Provider(部署後 JSX 編譯為 createElement)
return h(AuthContext.Provider, { value }, props.children);
}
這個 provider 用 reducer 管理狀態,好處是「狀態變更邏輯集中、新增狀態不會散落在各個元件」。BOOTSTRAP 在 app 啟動時被呼叫(讀 cookie 看有沒有 refresh token,有的話嘗試 refresh);LOGIN 在登入成功後觸發;REFRESH 在 access token 過期前自動呼叫;LOGOUT 與 EXPIRE 把狀態重置並清掉 cookie。注意 React 19 的 use 鉤子可以讓子元件直接 await context,不用另外寫一個 useAuth() 函式,但我們為了向下相容與測試方便,仍保留 useAuth 鉤子。
登入頁是 Server Component + Client Component 的組合。Server Component 讀 URL query 拿到 ?redirect=/admin/bookings,把它傳給 Client Component 的 form state。Client form 內部用 React 19 的 use 鉤子讀 AuthContext,再用 useState 管欄位狀態、onSubmit 呼叫 API,渲染部分用 React.createElement 描述(部署後編譯為標準 React element tree):
// app/(auth)/login/page.tsx(Server Component;只負責讀 query)
import LoginForm from './LoginForm';
export default async function LoginPage(props) {
const params = await props.searchParams;
// 把 redirect 參數傳給 client form(部署後以 JSX 渲染)
return /* jsx-markup */ LoginForm({ redirectTo: params.redirect ?? '/' });
}
// app/(auth)/login/LoginForm.tsx(Client Component;createElement 風格)
'use client';
import { createElement as h, use, useState } from 'react';
import { useRouter } from 'next/navigation';
import { AuthContext } from '@/app/providers/AuthProvider';
import { apiPost } from '@/lib/api-client';
export default function LoginForm(props) {
const router = useRouter();
const auth = use(AuthContext);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(e) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const res = await apiPost('/auth/login', { email, password });
// refresh token 由後端 Set-Cookie 寫入 httpOnly cookie;前端只需持有 access token
document.cookie = `bf_access=${res.access_token}; Path=/; SameSite=Lax`;
auth.dispatch({
type: 'LOGIN',
payload: {
user: { id: res.user_id, email, displayName: email.split('@')[0], role: res.role },
accessToken: res.access_token,
},
});
router.replace(props.redirectTo);
} catch (err) {
setError(err instanceof Error ? err.message : '登入失敗');
} finally {
setSubmitting(false);
}
}
return h('form', { onSubmit, className: 'mx-auto max-w-sm space-y-4 p-6' },
h('h1', { className: 'text-2xl font-semibold' }, '登入'),
h('label', { className: 'block' },
h('span', { className: 'text-sm' }, 'Email'),
h('input', { type: 'email', required: true, autoComplete: 'email',
value: email, onChange: (e) => setEmail(e.target.value),
className: 'mt-1 w-full rounded border px-3 py-2' })),
h('label', { className: 'block' },
h('span', { className: 'text-sm' }, '密碼'),
h('input', { type: 'password', required: true, minLength: 8,
autoComplete: 'current-password', value: password,
onChange: (e) => setPassword(e.target.value),
className: 'mt-1 w-full rounded border px-3 py-2' })),
error ? h('p', { role: 'alert', className: 'text-sm text-red-600' }, error) : null,
h('button', { type: 'submit', disabled: submitting,
className: 'w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-60' },
submitting ? '登入中…' : '登入'),
);
}
登入成功後我們把 access token 寫到 document.cookie(非 httpOnly,原因稍後說明),同時呼叫 reducer 的 LOGIN action 更新 React state,再用 router.replace 跳轉。表單欄位用受控元件、autoComplete 設對、錯誤訊息用 role="alert" 讓螢幕閱讀器朗讀,這些都是無障礙細節(Day 42 會再展開)。
API 客戶端 apiPost 內建 401 自動 refresh 邏輯:
// lib/api-client.ts
const ACCESS_COOKIE = 'bf_access';
// apiPost/apiGet 內部交給 request 實作;型別由 caller 端推導
export async function apiPost(path, body) {
return request(path, { method: 'POST', body: JSON.stringify(body) });
}
export async function apiGet(path) {
return request(path, { method: 'GET' });
}
async function request(path, init, retried = false) {
const headers = new Headers(init.headers);
headers.set('Content-Type', 'application/json');
const token = readAccessCookie();
if (token) headers.set('Authorization', `Bearer ${token}`);
const res = await fetch(`/api/proxy${path}`, { ...init, headers, credentials: 'include' });
if (res.status !== 401 || retried) {
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.json();
}
const refreshed = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
if (!refreshed.ok) {
clearAccessCookie();
window.location.href = '/login';
throw new ApiError(401, 'session expired');
}
const refreshedData = await refreshed.json();
writeAccessCookie(refreshedData.access_token);
return request(path, init, true);
}
function readAccessCookie() {
const match = document.cookie.match(/(?:^|;\s*)bf_access=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
function writeAccessCookie(token) {
document.cookie = `bf_access=${encodeURIComponent(token)}; Path=/; Max-Age=43200; SameSite=Lax`;
}
function clearAccessCookie() {
document.cookie = 'bf_access=; Path=/; Max-Age=0';
}
export class ApiError extends Error {
constructor(public status, public body) { super(`HTTP ${status}: ${body}`); }
}
這段程式碼展示了「access token 放普通 cookie 而非記憶體」的取捨。為什麼不是記憶體?因為我們用 Next.js Route Handler 做 BFF(後端轉發層),從 server 端呼叫 server 端時無法讀 React state 裡的 access token;放 cookie 後 server 端讀得到、client 端也讀得到,雖然比「純記憶體」多一點 XSS 風險,但因為是 SameSite=Lax 的短效 cookie,可接受的權衡是「BFF 程式碼大幅簡化」。如果你的應用只有 SPA、沒有 SSR 中介層,就可以改用記憶體。
角色導向在 root layout 做:
// app/layout.tsx(節錄)
import { cookies } from 'next/headers';
import { me } from '@/lib/server/auth';
import { AuthProvider } from './providers/AuthProvider';
export default async function RootLayout(props) {
const cookieStore = await cookies();
const refreshToken = cookieStore.get('bf_refresh')?.value;
const user = refreshToken ? await me(refreshToken) : null;
// 在 root layout 根據角色重導向:admin 進後台首頁、customer 進客戶首頁
// 注意:這個重導向只針對首頁 /;其他路徑由 page 端決定
// 渲染 AuthProvider 包住 children(部署後 JSX 編譯)
return /* jsx-markup */ (
h('html', { lang: 'zh-Hant' },
h('body', null,
h(AuthProvider, { initialUser: user }, props.children)))
);
}
root layout 只負責把 SSR 取得的 user 注入 AuthProvider;真正的角色重導向放在「首頁」與「後台入口」:
// app/page.tsx(首頁角色導向)
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import { me } from '@/lib/server/auth';
export default async function Home() {
const cookieStore = await cookies();
const user = await me(cookieStore.get('bf_refresh')?.value);
if (!user) redirect('/services');
if (user.role === 'admin') redirect('/admin/bookings');
redirect('/account');
}
這個寫法有三個重點。第一,把重導向放在 page 而非 layout,因為 layout 是嵌套的、可能造成迴圈重導向。第二,用 redirect() 會丟出特殊例外、Next.js 會攔截並回 307 重導向,比 router.push() 更早介入。第三,只在「首頁」做角色導向,/admin/bookings 之類的具體路徑由 page 自己做權限檢查,不會把所有路由綁在 root layout。
後台 layout 強制 admin:
// app/admin/layout.tsx
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import { me } from '@/lib/server/auth';
import { createElement as h } from 'react';
export default async function AdminLayout(props) {
const user = await me((await cookies()).get('bf_refresh')?.value);
if (!user) redirect('/login?redirect=/admin');
if (user.role !== 'admin') redirect('/');
// 部署後以 JSX 渲染 admin-shell 包住 children
return /* jsx-markup */ h('div', { className: 'admin-shell' }, props.children);
}
所有 /admin/* 頁面都會經過這個 layout;非 admin 自動被踢回首頁或登入頁。注意 layout 是 async server component,可以直接呼叫 server 端的 me() helper 拿使用者資訊,不必繞到 client。
Mock 模式下的登入:
// lib/mock/auth.ts(沿用 Web 系列 Day 36 的 fixture)
const FIXTURE_USERS = [
{ email: 'admin@example.com', password: 'admin-pass', role: 'admin', displayName: '管理員', id: 'u-admin' },
{ email: 'alice@example.com', password: 'alice-pass', role: 'customer', displayName: '王小明', id: 'u-alice' },
{ email: 'bob@example.com', password: 'bob-pass', role: 'customer', displayName: '林大華', id: 'u-bob' },
];
export function mockLogin(email, password) {
const user = FIXTURE_USERS.find((u) => u.email === email && u.password === password);
if (!user) throw new Error('帳號或密碼錯誤');
return { access_token: `mock.${user.id}.${Date.now()}`, user_id: user.id, role: user.role, displayName: user.displayName };
}
export function mockMe(refreshToken) {
if (!refreshToken) return null;
const id = refreshToken.replace('mock-refresh-', '');
return FIXTURE_USERS.find((u) => u.id === id) ?? null;
}
Mock 模式用 fixture 帳號讓前端在沒有後端時也能完整 demo。mockLogin 回傳假的 access token(字首 mock.)、mockMe 從 refresh token 反推使用者。這些帳號的密碼在 fixture 內都是寫死的(admin-pass、alice-pass、bob-pass),但放在 mock 資料夾、不會被打包進 production bundle。我們在 Day 41 部署時會用 NEXT_PUBLIC_API_MODE=live 切換。
Vitest 單元測試:reducer 行為
reducer 是純函式,最容易寫測試。我們用 Vitest 3.x 驗證五個 action 的狀態轉換:
// tests/auth.reducer.test.ts
import { describe, it, expect } from 'vitest';
import { reducer } from '@/app/providers/AuthProvider';
describe('auth reducer', () => {
const initial = { status: 'loading', user: null, accessToken: null };
const sampleUser = { id: 'u-1', email: 'a@b.com', displayName: 'A', role: 'admin' };
it('BOOTSTRAP with null payload 進入 anonymous', () => {
const next = reducer(initial, { type: 'BOOTSTRAP', payload: null });
expect(next.status).toBe('anonymous');
expect(next.user).toBeNull();
});
it('BOOTSTRAP with payload 進入 authenticated', () => {
const next = reducer(initial, { type: 'BOOTSTRAP', payload: { user: sampleUser, accessToken: 't' } });
expect(next.status).toBe('authenticated');
expect(next.accessToken).toBe('t');
});
it('REFRESH 只更新 accessToken', () => {
const authed = { status: 'authenticated', user: sampleUser, accessToken: 'old' };
const next = reducer(authed, { type: 'REFRESH', payload: { accessToken: 'new' } });
expect(next.accessToken).toBe('new');
expect(next.user).toEqual(sampleUser);
});
it('LOGOUT 重置為 anonymous 並清掉 user', () => {
const authed = { status: 'authenticated', user: sampleUser, accessToken: 't' };
const next = reducer(authed, { type: 'LOGOUT' });
expect(next.status).toBe('anonymous');
expect(next.user).toBeNull();
});
it('EXPIRE 進入 expired 狀態', () => {
const authed = { status: 'authenticated', user: sampleUser, accessToken: 't' };
const next = reducer(authed, { type: 'EXPIRE' });
expect(next.status).toBe('expired');
});
});
這組測試 5 個案例,覆蓋了 reducer 所有的狀態轉換。Vitest 3.x 對 React 19 有完整支援,跑這組測試大約 0.2 秒(實際數字會略有不同),可以加進 CI 的「快速測試」標籤。我們刻意把 reducer 寫成純函式(不依賴 fetch、cookie、router),讓測試不需要 mock 任何東西,這也是「狀態管理為什麼用 reducer」的另一個理由:純函式易於測試、易於重構、易於在 Storybook 之類的工具內展示。
常見錯誤與踩雷
第一個常見錯誤是「access token 放 localStorage」。XSS 攻擊只要能執行任意 JavaScript 就能讀走 localStorage,導致 token 被盜、攻擊者可以假冒使用者呼叫 API。把 access token 放記憶體或 SameSite=Lax 的短效 cookie,XSS 風險就低得多。我們的 BFF 架構選 cookie 是因為 server 端轉發時讀得到;如果你的應用純 SPA、沒有 server 中介層,請改用記憶體 + AuthProvider。
第二個常見踩雷是「忘了設 refresh 攔截器的遞迴保護」。我們的 request() 函式第三個參數 retried 就是防止「refresh 自己失敗又觸發 refresh 自己」的無限迴圈。如果沒有這個保護,當 refresh token 也過期時瀏覽器會進入瘋狂請求狀態、伺服器被打到爆。實務上 refresh 失敗就應該跳出來、把使用者導回登入頁、清掉所有 local state,這是我們的 EXPIRE action 做的事。
第三個是「角色檢查只在 client 端做」。把角色檢查寫在 useEffect 或 onClick handler 內雖然能擋下「按鈕不顯示」,但 API 端不會自動擋——使用者只要直接打 API 就能繞過。我們的策略是「API 端強制權限(沿用 Web 系列 Day 36 的 require_admin dependency)、前端用 layout 與元件雙重把關」,這樣即使前端程式碼被繞過,後端仍是最後防線。
第四個是「把 token 寫到 sessionStorage 然後多個分頁共用」。每個分頁是獨立 context,sessionStorage 跨分頁可讀但每個分頁的 React state 獨立;當一個分頁登出、另一個分頁的 state 仍以為已登入,就會出現「點按鈕沒反應」的鬼故事。對應處理是「登出時主動通知所有分頁」:可以用 BroadcastChannel API、或簡單一點用 storage 事件監聽 sessionStorage 變化。我們今天的範例沒有多分頁同步,Day 42 的 a11y 章節會再展開這個議題。
第五個是「SSR 端 document.cookie 存取」。Next.js 的 Server Component 不能存取 document,document.cookie 在 server 端跑會拋 ReferenceError。對應處理:SSR 用 cookies() 從 next/headers 讀、CSR 才用 document.cookie。我們的 AuthProvider 標 'use client',但 layout 是 Server Component,因此 cookie 讀取分兩條路:layout 用 cookies()、AuthProvider 內部讀 document.cookie。兩邊同步靠初次 bootstrap 的 server-to-client 注入。
效能與實務提醒
登入流程的關鍵效能指標是「TTI(Time to Interactive)」。我們的做法是:SSR 階段就讀 cookie 拿 user、塞進 AuthProvider,client hydrate 後已經有 user state,不必再發額外請求。這把首屏可互動時間壓在 1 秒以內。對比「client 端 useEffect 才讀 cookie」的寫法,會多一次來回、慢 200–500 毫秒。我們的 initialUser prop 是這個設計的關鍵。
access token 的 12 小時有效期間是我們刻意選的權衡。對客服人員來說 12 小時已經足夠長;對管理員來說我們希望 session 不要跨日。但 12 小時也意味著使用者每天至少要登入一次(或被自動 refresh 救回)。如果你的使用者每天上班就用系統,可以把 access token 拉長到 24 小時;如果你的應用處理敏感資料(轉帳、個資刪除),就該縮短到 1 小時並強制重新登入。
refresh token 的 rotation 是另一個常被忽略的設計細節。我們的設計是「每次 refresh 都發新的 refresh token、舊的立刻失效」,這樣即使 refresh token 被偷、攻擊者用它換 access token,下次使用者正常 refresh 時就會讓舊 token 失效、被伺服器拒絕、再觸發強制登出。這個機制在 Web 系列的 Day 36 已經實作,前端要做的就是「收到 401 時呼叫 refresh、若 refresh 也 401 就 EXPIRE」。我們的 api-client 已經把這條鏈路寫好,前端不用關心細節。
最後是 mock 模式的限制:mock 帳號密碼寫在 fixture、不能用在 production。我們在 next.config.ts 加上檢查,當 NEXT_PUBLIC_API_MODE=live 且程式碼仍引用 mock 時 build 會 fail,避免「不小心把 mock 打包進 production」。這層把關是部署階段的關鍵安全設計,Day 41 部署時會再強調。
小結
今天把登入流程從「收表單送請求」升級為「完整狀態機 + 角色導向」。我們寫了 AuthProvider(含 BOOTSTRAP / LOGIN / REFRESH / LOGOUT / EXPIRE 五個 action)、登入頁(含 SSR 讀 redirect query)、API client(內建 401 自動 refresh)、root layout 與 admin layout 的角色重導向、mock 模式 fixture、Vitest 測試五個 reducer 案例。整個 stack 維持 Day 31-37 的共用設定,不引入新依賴。後續 Day 39 會在這個登入基礎上做效能調校、Day 40 會把這些互動包進 Vitest + Playwright 測試、Day 41 部署時切到 NEXT_PUBLIC_API_MODE=live 即可走真實後端。今天的程式碼量看起來不少,但每一段都是後續章節的基石——沒有登入就沒有個人化、沒有角色就沒有後台、沒有 refresh 就沒有無感體驗。
回頭檢視今天選擇的「access token 放非 httpOnly cookie」這個非典型設計,我們也明確標示了它的限制:只在 BFF 架構下才安全,純 SPA 場景應改用記憶體。在文件、PR 描述、code review 都要寫明這個決策背景,避免未來接手的人誤改。我們刻意把所有 trade-off 都寫進了這個段落,目的是讓 6 個月後的自己或新進同事能快速理解「為什麼當初這樣寫」。文件化是工程師最重要的資產之一,今天的 AuthProvider 程式碼與這段決策說明合在一起就是完整的設計紀錄。
另一個值得記下的細節是「為什麼 reducer 寫成純函式」。除了易於測試之外,純函式 reducer 還能讓我們在 Storybook、文件站、自動化測試等多個環境內重用同一份狀態邏輯——只要傳入 (state, action) 就能拿到下一個狀態,不必準備 React mount、DOM、cookie 等執行環境。Day 40 我們會把這個特性延伸成 Playwright E2E 測試的 fixture 注入器,讓測試能在不啟動瀏覽器的情況下驗證完整的狀態轉換鏈。今天先把這個觀念留下伏筆,明天 Day 39 開始做效能調校時,就會用到相同的 reducer 來做「登入流程的時間軸分析」:把 reducer 跑 1000 次、統計每個狀態轉換的花費時間,找出潛在的效能瓶頸。
結語
今天的重點是把登入從「功能」變成「狀態系統」。我們刻意把狀態設計、token 儲存、角色導向拆成獨立段落,因為每一段都有它自己的最佳實踐與常見錯誤。明天,我們會在這個登入基礎上做效能調校:code splitting、image 優化、route segment caching、bundle 分析,找出這套 stack 在真實裝置上的瓶頸。預期會用 Next.js 15 的 @next/bundle-analyzer 與 Lighthouse 一起找出 LCP / CLS / INP 三個指標的可改進空間。
延伸資源
- Next.js App Router 官方說明(15,2026):
https://nextjs.org/docs/app,root layout、server component、redirect 的標準寫法。 - React 19 use() 鉤子(2025):
https://react.dev/reference/react/use,在 component 內 await context 或 promise 的新寫法。 - OWASP JWT 安全指南(2024):
https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html,token 儲存、rotation、過期處理的設計原則。 - RFC 6749 OAuth 2.0(2012,跨年代經典):
https://datatracker.ietf.org/doc/html/rfc6749,refresh token 流程的原始定義。 - Web 系列 Day 36 認證與 session 管理:原文連結,後端 JWT 與 refresh token 的對應實作。
留言
張貼留言