跳到主要內容

FE Day 38 登入流程與角色導向



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/*.ts fixture,不打真實 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 儲存位置有四種選擇,安全性由高到低:

  1. httpOnly cookie:瀏覽器 JavaScript 完全碰不到,XSS 也偷不走;但 CSRF 風險需用 SameSite 防護。
  2. 記憶體(React state / Context):重新整理就消失,需要 refresh 機制補回來;沒有任何持久化風險。
  3. sessionStorage:分頁關閉就消失,比 localStorage 安全但仍有 XSS 風險。
  4. 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 的對應實作。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...