跳至主要内容

US-002: 角色權限控制

User Story

作為 系統管理員 我想要 根據用戶的角色限制其可訪問的功能模組(Applet) 以便 確保數據安全與職責分離


驗收標準 (Acceptance Criteria)

Scenario 1: 店員可以訪問訂單管理功能

  • Given 我是已登入的店員(ROLE_SALES
  • When 我從 Application Launcher 開啟「訂單管理」
  • Then 系統應允許我訪問訂單列表頁面
  • And 我應看到「創建訂單」按鈕
  • And 我應能夠查看所有訂單(我的租戶內)

Scenario 2: 設計師可以訪問設計工作台

  • Given 我是已登入的設計師(ROLE_FLORIST
  • When 我從 Application Launcher 開啟「設計工作台」
  • Then 系統應顯示分配給我的訂單
  • And 我應能夠上傳作品照片
  • And 我應能夠更新訂單狀態

Scenario 3: 設計師嘗試訪問系統設定頁面被拒絕

  • Given 我是已登入的設計師(ROLE_FLORIST
  • When 我嘗試訪問 /settings 頁面(例如透過手動輸入 URL)
  • Then 系統應顯示 403 錯誤頁面「您沒有權限訪問此頁面」
  • And 系統應記錄未授權訪問嘗試至審計日誌

Scenario 4: 送貨員僅能訪問配送相關功能

  • Given 我是已登入的送貨員(ROLE_DELIVERY
  • When 我訪問系統
  • Then Application Launcher 應僅顯示以下 Applet:
    • 首頁
    • 配送工作台(待配送訂單)
    • 個人設定
  • And 我不應看到「訂單管理」、「客戶管理」、「商品管理」選單
  • And 我應能夠上傳簽收照片

Scenario 5: API 層級權限檢查 - 店員呼叫創建訂單 API 成功

  • Given 我是已登入的店員(ROLE_SALES
  • And 我的 Access Token 包含扁平化的 roles: ["ROLE_SALES"]
  • When 我發送 POST /api/v1/orders 請求
  • And 請求包含有效的訂單數據
  • Then 系統應成功創建訂單
  • And API 應返回 201 Created 狀態碼

Scenario 5a: 店主的 roles 陣列包含繼承的角色

  • Given 我是已登入的店主(ROLE_OWNER
  • And 我的 Access Token 包含扁平化的 roles: ["ROLE_OWNER", "ROLE_MANAGER", "ROLE_SALES", "ROLE_ACCOUNTANT", "ROLE_PURCHASER", "ROLE_FLORIST", "ROLE_DELIVERY"]
  • When 我訪問任何 Applet
  • Then 系統應允許我訪問(因為 roles 陣列包含所有繼承的角色)

Scenario 6: API 層級權限檢查 - 由後端 Spring Security 處理

  • Given 我是已登入的用戶
  • And 我嘗試呼叫沒有權限的 API
  • When 後端 Spring Security 檢查權限
  • Then API 應返回 403 Forbidden 狀態碼
  • And 響應訊息應為「您沒有權限執行此操作」
  • And 前端應顯示錯誤訊息
  • Note Mock 環境不做權限檢查,只做認證(token 有效性)

Scenario 7: Token 過期時自動刷新並重試請求

  • Given 我是已登入的店員
  • And 我的 Access Token 即將過期(剩餘 1 分鐘)
  • When 我發送任意 API 請求
  • Then 系統應自動使用 Refresh Token 刷新 Access Token
  • And 系統應使用新的 Access Token 重試原始請求
  • And 我應感覺不到任何中斷

Scenario 8: Refresh Token 過期時強制重新登入

  • Given 我是已登入的店員
  • And 我的 Refresh Token 已過期
  • When 系統嘗試刷新 Access Token
  • Then 系統應顯示「登入已過期,請重新登入」訊息
  • And 系統應清除所有本地儲存的 Token
  • And 系統應重定向我至登入頁面

業務規則 (Business Rules)

  1. 角色定義

    角色說明
    ROLE_ADMIN系統管理員
    ROLE_OWNER店主
    ROLE_MANAGER店長
    ROLE_SALES銷售人員
    ROLE_ACCOUNTANT會計
    ROLE_PURCHASER採購
    ROLE_FLORIST花藝師
    ROLE_DELIVERY配送員
  2. Applet 層級權限模型

    本系統採用 Applet 層級權限設計

    層級用途檢查方式使用場景
    Role(角色)Applet 入口控制RoleGuard選單顯示、頁面訪問
    API 權限後端 Spring Security403 響應功能操作

    核心原則

    • 前端權限以 Applet 為單位
    • 進入 Applet 後所有功能都可用
    • 如需部分功能,設計專屬 Applet
    • 後端 Spring Security 負責 API 層權限控制
    • Mock 只做認證,不做權限檢查
  3. 角色繼承(Role Hierarchy)

    後端 Spring Security 使用角色繼承,前端收到扁平化的 roles 陣列:

    ROLE_ADMIN > ROLE_OWNER > ROLE_MANAGER > ROLE_SALES
    > ROLE_MANAGER > ROLE_ACCOUNTANT
    > ROLE_PURCHASER
    > ROLE_FLORIST
    > ROLE_DELIVERY

    扁平化範例

    • 店主登入 → roles: [OWNER, MANAGER, SALES, ACCOUNTANT, PURCHASER, FLORIST, DELIVERY]
    • 店員登入 → roles: [SALES]

    設計優點

    • allowedRoles 只需指定最基本的角色
    • 例如:訂單管理 [SALES],店主自動有權限(因為繼承 SALES)
  4. 權限檢查層級

    • 路由層級: 用戶訪問特定 Applet 時檢查(RoleGuard,使用 roles 陣列交集)
    • API 層級: 後端 Spring Security 強制檢查(正式環境)
  5. Role → Applet 對應(簡化版)

    由於角色繼承,allowedRoles 只需指定最基本的角色:

    AppletallowedRoles說明
    orders[SALES]OWNER/MANAGER 自動有權限
    customers[SALES]OWNER/MANAGER 自動有權限
    products[PURCHASER, FLORIST]OWNER 自動有權限
    sales[SALES]OWNER 自動有權限
    design[FLORIST]OWNER 自動有權限
    delivery[DELIVERY]OWNER 自動有權限
    reports[ACCOUNTANT]OWNER/MANAGER 自動有權限
    analytics[ACCOUNTANT]OWNER/MANAGER 自動有權限
    settings[ADMIN]僅 ADMIN

    共用 Applet(空陣列 = 所有角色可見): home, calendar, messages

  6. 多租戶隔離

    • 所有權限檢查都在租戶層級內執行
    • 用戶僅能訪問其所屬租戶的資源
    • 跨租戶訪問視為無權限(返回 404 而非 403)
  7. Token 刷新策略

    • Access Token 剩餘時間 < 5 分鐘時自動刷新
    • 刷新失敗時清除 Token 並強制重新登入
    • 刷新過程對用戶透明(背景執行)

UI/UX 需求 (UI/UX Requirements)

動態選單顯示

使用 allowedRoles 配置控制選單可見性,定義於 src/layouts/menu-config.ts

選單配置範例:

import { Role } from '@/types/auth';

menuItems = [
{ id: 'home', label: '首頁', path: '/', icon: Home },
{ id: 'orders', label: '訂單管理', path: '/orders', icon: ShoppingBag, allowedRoles: [Role.ADMIN, Role.OWNER, Role.MANAGER, Role.SALES, Role.ACCOUNTANT] },
{ id: 'customers', label: '客戶管理', path: '/customers', icon: Users, allowedRoles: [Role.ADMIN, Role.OWNER, Role.MANAGER, Role.SALES] },
{ id: 'settings', label: '系統設定', path: '/settings', icon: Settings, allowedRoles: [Role.ADMIN] },
]

店主 / 管理者 (ROLE_OWNER, ROLE_ADMIN):

├── 首頁
├── 訂單管理
├── 客戶管理
├── 商品管理
├── 報表分析
└── 系統設定(僅 ROLE_ADMIN)

店員 (ROLE_SALES):

├── 首頁
├── 訂單管理
└── 客戶管理

設計師 (ROLE_FLORIST):

├── 首頁
└── 設計工作台

送貨員 (ROLE_DELIVERY):

├── 首頁
└── 配送工作台

進入 Applet 後的功能

核心原則:進入 Applet 即可使用所有功能

Applet進入後可用的功能
訂單管理創建、編輯、刪除、狀態更新
客戶管理創建、編輯、刪除、匯出
設計工作台查看任務、上傳照片、更新狀態
配送工作台查看任務、上傳照片、更新狀態

權限不足時的反饋

前端路由保護:

  • 顯示 403 錯誤頁面
  • 提示訊息: 「您沒有權限訪問此頁面」
  • 提供「返回首頁」按鈕

API 請求被拒:

  • Toast 通知(紅色): 「您沒有權限執行此操作」
  • 保持在當前頁面(不重定向)

Token 過期:

  • Toast 通知(黃色): 「登入已過期,請重新登入」
  • 2 秒後重定向至登入頁面

技術規格 (Technical Specifications)

前端組件架構

核心檔案:

檔案用途
src/types/auth.ts角色類型定義(RoleName, Role, VALID_ROLES, ROLE_HIERARCHY
src/hooks/use-permission.ts提供當前用戶資訊
src/routes/role-guard.tsx路由層級角色守衛(使用 roles 陣列交集檢查)
src/layouts/menu-config.ts選單配置與角色過濾
src/config/applet-registry.tsApplet 註冊與角色過濾(簡化版 allowedRoles)
src/features/iam/me-slice.ts用戶狀態管理
src/mocks/utils/jwt.tsMock JWT 生成(使用 ROLE_HIERARCHY)
src/mocks/handlers/auth.tsMock 登入 API(回傳扁平化 roles)

角色類型定義

實作位置: src/types/auth.ts

// 角色名稱類型(Spring Security 格式)
export type RoleName =
| 'ROLE_ADMIN'
| 'ROLE_OWNER'
| 'ROLE_MANAGER'
| 'ROLE_SALES'
| 'ROLE_ACCOUNTANT'
| 'ROLE_PURCHASER'
| 'ROLE_FLORIST'
| 'ROLE_DELIVERY';

// 角色常數(便於使用)
export const Role = {
ADMIN: 'ROLE_ADMIN',
OWNER: 'ROLE_OWNER',
MANAGER: 'ROLE_MANAGER',
SALES: 'ROLE_SALES',
ACCOUNTANT: 'ROLE_ACCOUNTANT',
PURCHASER: 'ROLE_PURCHASER',
FLORIST: 'ROLE_FLORIST',
DELIVERY: 'ROLE_DELIVERY',
} as const;

// 有效角色列表
export const VALID_ROLES: readonly RoleName[] = [
Role.ADMIN,
Role.OWNER,
Role.MANAGER,
Role.SALES,
Role.ACCOUNTANT,
Role.PURCHASER,
Role.FLORIST,
Role.DELIVERY,
] as const;

// 角色繼承映射(模擬 Spring Security Role Hierarchy)
export const ROLE_HIERARCHY: Record<RoleName, RoleName[]> = {
[Role.ADMIN]: [Role.ADMIN, Role.OWNER, Role.MANAGER, Role.SALES, Role.ACCOUNTANT, Role.PURCHASER, Role.FLORIST, Role.DELIVERY],
[Role.OWNER]: [Role.OWNER, Role.MANAGER, Role.SALES, Role.ACCOUNTANT, Role.PURCHASER, Role.FLORIST, Role.DELIVERY],
[Role.MANAGER]: [Role.MANAGER, Role.SALES, Role.ACCOUNTANT],
[Role.SALES]: [Role.SALES],
[Role.ACCOUNTANT]: [Role.ACCOUNTANT],
[Role.PURCHASER]: [Role.PURCHASER],
[Role.FLORIST]: [Role.FLORIST],
[Role.DELIVERY]: [Role.DELIVERY],
};

usePermission Hook

實作位置: src/hooks/use-permission.ts

提供當前用戶資訊,用於 UI 顯示:

import { usePermission } from '@/hooks/use-permission';

function MyComponent() {
const { currentUser } = usePermission();

return (
<span>Role: {currentUser?.role}</span>
);
}

注意:前端權限控制以 Applet 為單位,由 RoleGuard 在路由層處理。組件層不需要做細粒度的權限檢查。

路由守衛組件

RoleGuard - Applet 層級守衛

實作位置: src/routes/role-guard.tsx

使用 allowedRoles 列表與用戶的 roles 陣列做交集檢查:

// RoleGuard 內部邏輯
const hasPermission =
allowedRoles.length === 0 ||
currentUser.roles.some((role) => allowedRoles.includes(role));
import { RoleGuard } from '@/routes/role-guard';
import { Role } from '@/types/auth';

// 在路由配置中使用(簡化版:只需指定最基本的角色)
{
path: 'orders/*',
element: (
<RoleGuard allowedRoles={[Role.SALES]}> {/* OWNER/MANAGER 自動有權限 */}
<OrderApplet />
</RoleGuard>
),
}

{
path: 'design/*',
element: (
<RoleGuard allowedRoles={[Role.FLORIST]}> {/* OWNER 自動有權限 */}
<DesignApplet />
</RoleGuard>
),
}

選單權限過濾

實作位置: src/layouts/menu-config.ts

// 根據用戶角色陣列過濾選單項目(交集檢查)
export function filterMenuByRole(items: MenuItem[], userRoles: RoleName[]): MenuItem[] {
return items.filter(item => {
if (!item.allowedRoles || item.allowedRoles.length === 0) return true;
return userRoles.some(role => item.allowedRoles.includes(role));
});
}

Application Launcher 角色過濾

實作位置: src/config/applet-registry.ts

// Applet 元數據結構
interface AppletMetadata {
id: string;
name: string;
icon: LucideIcon;
iconColor: string;
category: AppletCategory;
path: string;
allowedRoles: RoleName[]; // 空陣列 = 所有角色可見
keywords?: string[];
}

// 根據用戶角色陣列過濾可用 Applet(交集檢查)
export function filterAppletsByRole(
applets: AppletMetadata[],
userRoles: RoleName[]
): AppletMetadata[] {
return applets.filter(applet => {
if (applet.allowedRoles.length === 0) return true;
return userRoles.some(role => applet.allowedRoles.includes(role));
});
}

Mock 認證中間件

實作位置: src/mocks/middleware/auth-middleware.ts

Mock 只做認證(驗證 token 有效性),不做權限檢查:

export function checkAuth(request: Request): AuthCheckResult {
// 驗證 token 有效性
// 不做任何權限檢查
// 權限控制由後端 Spring Security 負責
}

API 請求攔截器

實作位置: src/services/apiClient.ts

// 請求攔截器 - 自動附加 Access Token
apiClient.interceptors.request.use((config) => {
const accessToken = store.getState().auth.accessToken;
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});

// 響應攔截器 - 處理 401/403
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
// 401 → 嘗試刷新 Token
// 403 → 顯示權限不足訊息
// ...
}
);

Token 刷新 API

  • 端點: POST /api/v1/auth/refresh
  • 權限要求: 需要有效的 Refresh Token(HttpOnly Cookie)
  • 請求: 無 Body(Refresh Token 在 Cookie 中)

成功響應 (200 OK):

{
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 900 // 15 分鐘
}

錯誤響應 (401 Unauthorized):

{
"error": "INVALID_REFRESH_TOKEN",
"message": "Refresh Token 無效或已過期",
"timestamp": "2025-10-31T10:30:00Z"
}

數據模型

  • 主要 Entity: User, Role
  • 詳細定義: 參考用戶數據模型(data-models/user-role.md 待建立)

SBE 場景 (Specification by Example)

詳細的測試場景與範例數據:


估算 (Estimation)

  • Story Points: 5 點(簡化後)
  • 預估工時: 2-3 天
  • 複雜度: 中

工作拆分

  1. 後端開發 (1 天)

    • 實作 API 層級權限檢查(Spring Security)
    • 實作 Token 刷新端點 (POST /api/v1/auth/refresh)
    • 審計日誌記錄未授權訪問
  2. 前端開發 (1-1.5 天)

    • 實作 RoleGuard 組件
    • 實作 usePermission Hook
    • 實作 API 攔截器(Token 附加與自動刷新)
    • 動態選單顯示(根據角色)
    • 403 錯誤頁面
  3. 整合與測試 (0.5 天)

    • 整合測試(多種角色的 Applet 訪問)
    • E2E 測試(不同角色訪問不同 Applet)

依賴 (Dependencies)

前置條件

  • US-001: 用戶登入 - 需要 JWT Token 機制

並行開發

  • 可與 US-003(基礎 Layout)並行開發

外部依賴

  • 後端 API: Spring Security 權限檢查(正式環境)

測試策略 (Testing Strategy)

單元測試

  • 測試 filterMenuByRole 函數
  • 測試路由保護邏輯(RoleGuard 組件)
  • 測試 Token 刷新邏輯

整合測試

  • 測試不同角色訪問 Applet(使用 MSW)
  • 測試 Token 過期與自動刷新
  • 測試 Refresh Token 過期強制登出

E2E 測試

  • 測試店員訪問訂單管理頁面(成功)
  • 測試設計師訪問系統設定頁面(被拒絕,顯示 403)
  • 測試設計師訪問設計工作台(成功)
  • 測試 Token 過期後自動刷新並重試請求

安全性測試

  • 測試 Token 篡改(修改 Token 中的角色)
  • 測試跨租戶訪問(嘗試訪問其他租戶的資源)

完成定義 (Definition of Done)

  • 前端 RoleGuard 與選單過濾實作完成
  • Token 自動刷新機制實作完成
  • 整合測試通過(多種角色的 Applet 訪問驗證)
  • E2E 測試通過(至少涵蓋 3 種角色的訪問場景)
  • 單元測試覆蓋率 > 80%
  • 安全性測試通過(Token 篡改、跨租戶訪問)
  • Code Review 通過
  • 403 錯誤頁面實作完成
  • 產品經理驗收通過

備註 (Notes)

設計決策

  • 為何採用 Applet 層級權限? 簡化前端權限邏輯,權限控制由後端負責。
  • 為何 Mock 不做權限檢查? Mock 是開發與測試環境,不需要模擬權限拒絕。真正的權限控制由後端 Spring Security 處理。
  • 如果某角色只需要部分功能怎麼辦? 為該角色設計專屬的 Applet(如 Sales Applet、Design Applet、Delivery Applet),而非在組件層做權限判斷。
  • 為何跨租戶訪問返回 404 而非 403? 避免洩漏資源是否存在的資訊,提升安全性。
  • 為何自動刷新 Token? 提升用戶體驗,避免頻繁重新登入。

相關文件

  • ADR-007: Applet 層級權限模型(decision-records/007-role-authority-permission-model.md 待建立)

最後更新: 2025-12-20