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)
-
角色定義
角色 說明 ROLE_ADMIN系統管理員 ROLE_OWNER店主 ROLE_MANAGER店長 ROLE_SALES銷售人員 ROLE_ACCOUNTANT會計 ROLE_PURCHASER採購 ROLE_FLORIST花藝師 ROLE_DELIVERY配送員 -
Applet 層級權限模型
本系統採用 Applet 層級權限設計:
層級 用途 檢查方式 使用場景 Role(角色) Applet 入口控制 RoleGuard 選單顯示、頁面訪問 API 權限 後端 Spring Security 403 響應 功能操作 核心原則:
- 前端權限以 Applet 為單位
- 進入 Applet 後所有功能都可用
- 如需部分功能,設計專屬 Applet
- 後端 Spring Security 負責 API 層權限控制
- Mock 只做認證,不做權限檢查
-
角色繼承(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)
- 店主登入 →
-
權限檢查層級
- 路由層級: 用戶訪問特定 Applet 時檢查(
RoleGuard,使用 roles 陣列交集) - API 層級: 後端 Spring Security 強制檢查(正式環境)
- 路由層級: 用戶訪問特定 Applet 時檢查(
-
Role → Applet 對應(簡化版)
由於角色繼承,
allowedRoles只需指定最基本的角色:Applet allowedRoles 說明 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
-
多租戶隔離
- 所有權限檢查都在租戶層級內執行
- 用戶僅能訪問其所屬租戶的資源
- 跨租戶訪問視為無權限(返回 404 而非 403)
-
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.ts | Applet 註冊與角色過濾(簡化版 allowedRoles) |
src/features/iam/me-slice.ts | 用戶狀態管理 |
src/mocks/utils/jwt.ts | Mock JWT 生成(使用 ROLE_HIERARCHY) |
src/mocks/handlers/auth.ts | Mock 登入 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 天)
- 實作 API 層級權限檢查(Spring Security)
- 實作 Token 刷新端點 (
POST /api/v1/auth/refresh) - 審計日誌記錄未授權訪問
-
前端開發 (1-1.5 天)
- 實作
RoleGuard組件 - 實作
usePermissionHook - 實作 API 攔截器(Token 附加與自動刷新)
- 動態選單顯示(根據角色)
- 403 錯誤頁面
- 實作
-
整合與測試 (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