From f00fc940a9fda9116549d9fc4eda2099edcaa8c8 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 19 Mar 2026 17:18:21 +0800 Subject: [PATCH] =?UTF-8?q?[DOCS]=20=E6=9B=B4=E6=96=B0=20RBAC=20=E5=AF=A6?= =?UTF-8?q?=E4=BD=9C=E8=A6=8F=E7=AF=84=E8=88=87=E8=A7=92=E8=89=B2=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E6=B5=81=E7=A8=8B=E5=BB=BA=E8=AD=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/rules/framework.md | 4 +- .agents/rules/rbac-rules.md | 18 ++ .../Controllers/Admin/CompanyController.php | 34 +++- .../Admin/PermissionController.php | 161 ++++++++++++++++-- lang/en.json | 1 + lang/zh_TW.json | 1 + resources/css/app.css | 60 +++++++ .../views/admin/companies/index.blade.php | 18 +- .../admin/data-config/accounts.blade.php | 141 ++++++++++----- .../views/admin/permission/roles.blade.php | 23 ++- .../views/components/loading-screen.blade.php | 51 ++++++ resources/views/dashboard.blade.php | 20 +++ resources/views/layouts/admin.blade.php | 16 ++ 13 files changed, 474 insertions(+), 74 deletions(-) create mode 100644 resources/views/components/loading-screen.blade.php diff --git a/.agents/rules/framework.md b/.agents/rules/framework.md index 98a6412..3d1671b 100644 --- a/.agents/rules/framework.md +++ b/.agents/rules/framework.md @@ -91,7 +91,7 @@ trigger: always_on * **本地測試網址**:`http://localhost:8090/` (注意:非 8000 或 8080) * **預設管理員帳號**:`admin` -* **預設管理員密碼**:`password` +* **預設管理員密碼**:`Star82779061` > [!IMPORTANT] -> 在執行 `open_browser_url` 或進行 E2E 測試時,請務必優先確認 Port 是否為 `8090`,以避免連線至錯誤的服務環境。 +> 在執行 `open_browser_url` 或進行 E2E 測試時,請務必優先確認 Port 是否為 `8090`,以避免連線至錯誤的服務環境。 \ No newline at end of file diff --git a/.agents/rules/rbac-rules.md b/.agents/rules/rbac-rules.md index bf845e0..2bdb1f7 100644 --- a/.agents/rules/rbac-rules.md +++ b/.agents/rules/rbac-rules.md @@ -1,3 +1,7 @@ +--- +trigger: always_on +--- + # 多租戶與權限架構實作規範 (RBAC Rules) 本文件定義 Star Cloud 系統的多租戶與權限(RBAC)實作標準,開發者必須嚴格遵守以下準則,以確保資料隔離與安全性。 @@ -50,3 +54,17 @@ ## 4. API 安全 - 所有的 API Route 應預設包含 `CheckTenantAccess` Middleware。 - 嚴禁透過 URL 修改 ID 存取不屬於該租戶的資料,必須依賴 `company_id` 的 Scope 過濾。 + +--- + +## 5. 客戶初次建立與角色初始化 (Role Provisioning) + +### 5.1 初始角色建立 +當系統管理員為新客戶(該租戶尚未有任何角色)建立第一個帳號時,應遵循以下邏輯: +1. **選取範本**:從系統預設的「全域角色範本」(`company_id = null` 且 `is_system = 0`)中選取一個作為基礎。 +2. **自動克隆**:系統會將該範本的權限內容複製一份至該租戶下。 +3. **統一命名**:克隆後的角色名稱在該租戶公司內應統一命名為**「管理員」**。 +4. **帳號綁定**:該新客戶帳號將被指派至此新建立的「管理員」角色。 + +### 5.2 角色權限維護 +- 初始建立後,該租戶的「管理員」角色即成為獨立資源,可由具有權限的帳號進行細部調整。 diff --git a/app/Http/Controllers/Admin/CompanyController.php b/app/Http/Controllers/Admin/CompanyController.php index e2ee1a7..6858ab8 100644 --- a/app/Http/Controllers/Admin/CompanyController.php +++ b/app/Http/Controllers/Admin/CompanyController.php @@ -32,7 +32,12 @@ class CompanyController extends Controller $per_page = $request->input('per_page', 10); $companies = $query->latest()->paginate($per_page)->withQueryString(); - return view('admin.companies.index', compact('companies')); + // 取得可供選擇的客戶角色範本 (is_system = 0, company_id = null) + $template_roles = \App\Models\System\Role::where('is_system', 0) + ->whereNull('company_id') + ->get(); + + return view('admin.companies.index', compact('companies', 'template_roles')); } /** @@ -54,6 +59,7 @@ class CompanyController extends Controller 'admin_username' => 'nullable|string|max:255|unique:users,username', 'admin_password' => 'nullable|string|min:8', 'admin_name' => 'nullable|string|max:255', + 'admin_role' => 'nullable|string|exists:roles,name', ]); DB::transaction(function () use ($validated) { @@ -79,8 +85,30 @@ class CompanyController extends Controller 'status' => 1, ]); - // 綁定客戶管理員角色 - $user->assignRole('tenant-admin'); + // 角色初始化與克隆邏輯 (優先使用選擇的角色,否則使用預設) + $selected_role_name = $validated['admin_role'] ?? '通用客戶角色範本'; + $role_to_assign = '管理員'; + + $template_role = \App\Models\System\Role::where('name', $selected_role_name) + ->whereNull('company_id') + ->where('is_system', 0) + ->first(); + + if ($template_role) { + // 克隆範本為該公司的「管理員」 + $clonedRole = \App\Models\System\Role::query()->create([ + 'name' => '管理員', + 'guard_name' => 'web', + 'company_id' => $company->id, + 'is_system' => false, + ]); + $clonedRole->syncPermissions($template_role->permissions); + } else { + // 如果找不到選定的角色範本,退而求其次嘗試指派現有角色 (通常不應發生) + $role_to_assign = $selected_role_name; + } + + $user->assignRole($role_to_assign); } }); diff --git a/app/Http/Controllers/Admin/PermissionController.php b/app/Http/Controllers/Admin/PermissionController.php index d4688a3..54c8d5e 100644 --- a/app/Http/Controllers/Admin/PermissionController.php +++ b/app/Http/Controllers/Admin/PermissionController.php @@ -16,10 +16,7 @@ class PermissionController extends Controller // 租戶隔離:租戶只能看到自己公司的角色 + 系統角色 (company_id is null) if (!$user->isSystemAdmin()) { - $query->where(function($q) use ($user) { - $q->where('company_id', $user->company_id) - ->orWhereNull('company_id'); - }); + $query->where('company_id', $user->company_id); } // 搜尋:角色名稱 @@ -58,15 +55,21 @@ class PermissionController extends Controller */ public function storeRole(Request $request) { + $is_system = auth()->user()->isSystemAdmin() && $request->boolean('is_system'); + $company_id = $is_system ? null : auth()->user()->company_id; + $validated = $request->validate([ - 'name' => 'required|string|max:255|unique:roles,name', + 'name' => [ + 'required', 'string', 'max:255', + \Illuminate\Validation\Rule::unique('roles', 'name')->where(function ($query) use ($company_id) { + return $query->where('company_id', $company_id); + }) + ], 'permissions' => 'nullable|array', 'permissions.*' => 'string|exists:permissions,name', ]); - $is_system = auth()->user()->isSystemAdmin() && $request->boolean('is_system'); - - $role = \App\Models\System\Role::create([ + $role = \App\Models\System\Role::query()->create([ 'name' => $validated['name'], 'guard_name' => 'web', 'company_id' => $is_system ? null : auth()->user()->company_id, @@ -92,8 +95,18 @@ class PermissionController extends Controller { $role = \App\Models\System\Role::findOrFail($id); + $is_system = $role->is_system; + $company_id = $role->company_id; + $validated = $request->validate([ - 'name' => 'required|string|max:255|unique:roles,name,' . $id, + 'name' => [ + 'required', 'string', 'max:255', + \Illuminate\Validation\Rule::unique('roles', 'name') + ->ignore($id) + ->where(function ($query) use ($company_id) { + return $query->where('company_id', $company_id); + }) + ], 'permissions' => 'nullable|array', 'permissions.*' => 'string|exists:permissions,name', ]); @@ -177,10 +190,7 @@ class PermissionController extends Controller $companies = auth()->user()->isSystemAdmin() ? \App\Models\System\Company::all() : collect(); $roles_query = \App\Models\System\Role::where('name', '!=', 'super-admin'); if (!auth()->user()->isSystemAdmin()) { - $roles_query->where(function($q) { - $q->where('company_id', auth()->user()->company_id) - ->orWhereNull('company_id'); - }); + $roles_query->where('company_id', auth()->user()->company_id); } $roles = $roles_query->get(); @@ -198,7 +208,7 @@ class PermissionController extends Controller $validated = $request->validate([ 'name' => 'required|string|max:255', 'username' => 'required|string|max:255|unique:users,username', - 'email' => 'nullable|email|max:255|unique:users,email', + 'email' => 'required|email|max:255|unique:users,email', 'password' => 'required|string|min:8', 'role' => 'required|string', 'status' => 'required|boolean', @@ -206,17 +216,73 @@ class PermissionController extends Controller 'phone' => 'nullable|string|max:20', ]); + $company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id; + + // 查找角色:優先尋找該公司的角色,若無則尋找全域範本 + $role = \App\Models\System\Role::where('name', $validated['role']) + ->where(function($q) use ($company_id) { + $q->where('company_id', $company_id)->orWhereNull('company_id'); + }) + ->first(); + + if (!$role) { + return redirect()->back()->with('error', __('Role not found.')); + } + + // 驗證角色與公司的匹配性 (RBAC Safeguard) + if ($company_id !== null) { + // 如果是租戶帳號,不能選各項系統角色 (is_system = 1) + if ($role->is_system) { + return redirect()->back()->with('error', __('System roles cannot be assigned to tenant accounts.')); + } + // 如果角色有特定的 company_id,必須匹配 + if ($role->company_id !== null && $role->company_id != $company_id) { + return redirect()->back()->with('error', __('This role belongs to another company and cannot be assigned.')); + } + } else { + // 如果是系統層級帳號,只能選系統角色 (is_system = 1) + if (!$role->is_system) { + return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.')); + } + } + + // 角色初始化與克隆邏輯 (只有 super-admin 在幫空白公司開帳號時觸發) + $role_to_assign = $validated['role']; + $company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id; + + if ($company_id && $role && !$role->is_system && $role->company_id === null) { + // 檢查該公司是否已有名為「管理員」的角色 + $existingRole = \App\Models\System\Role::where('company_id', $company_id) + ->where('name', '管理員') + ->first(); + + if (!$existingRole) { + // 克隆範本為該公司的「管理員」 + $clonedRole = \App\Models\System\Role::query()->create([ + 'name' => '管理員', + 'guard_name' => 'web', + 'company_id' => $company_id, + 'is_system' => false, + ]); + $clonedRole->syncPermissions($role->permissions); + $role_to_assign = '管理員'; + } else { + // 如果已存在名為「管理員」的角色,則直接使用它 + $role_to_assign = '管理員'; + } + } + $user = \App\Models\System\User::create([ 'name' => $validated['name'], 'username' => $validated['username'], 'email' => $validated['email'], 'password' => \Illuminate\Support\Facades\Hash::make($validated['password']), 'status' => $validated['status'], - 'company_id' => auth()->user()->isSystemAdmin() ? $validated['company_id'] : auth()->user()->company_id, + 'company_id' => $company_id, 'phone' => $validated['phone'], ]); - $user->assignRole($validated['role']); + $user->assignRole($role_to_assign); return redirect()->back()->with('success', __('Account created successfully.')); } @@ -235,7 +301,7 @@ class PermissionController extends Controller $validated = $request->validate([ 'name' => 'required|string|max:255', 'username' => 'required|string|max:255|unique:users,username,' . $id, - 'email' => 'nullable|email|max:255|unique:users,email,' . $id, + 'email' => 'required|email|max:255|unique:users,email,' . $id, 'password' => 'nullable|string|min:8', 'role' => 'required|string', 'status' => 'required|boolean', @@ -243,6 +309,35 @@ class PermissionController extends Controller 'phone' => 'nullable|string|max:20', ]); + $target_company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id; + + // 查找角色:優先尋找該公司的角色,若無則尋找全域範本 + $roleObj = \App\Models\System\Role::where('name', $validated['role']) + ->where(function($q) use ($target_company_id) { + $q->where('company_id', $target_company_id)->orWhereNull('company_id'); + }) + ->first(); + + if (!$roleObj) { + return redirect()->back()->with('error', __('Role not found.')); + } + + // 驗證角色與公司的匹配性 (RBAC Safeguard) + if ($user->id !== auth()->id()) { // 排除編輯自己 (super-admin 有特殊邏輯) + if ($target_company_id !== null) { + if ($roleObj->is_system) { + return redirect()->back()->with('error', __('System roles cannot be assigned to tenant accounts.')); + } + if ($roleObj->company_id !== null && $roleObj->company_id != $target_company_id) { + return redirect()->back()->with('error', __('This role belongs to another company and cannot be assigned.')); + } + } else { + if (!$roleObj->is_system) { + return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.')); + } + } + } + $updateData = [ 'name' => $validated['name'], 'username' => $validated['username'], @@ -265,13 +360,37 @@ class PermissionController extends Controller $updateData['password'] = \Illuminate\Support\Facades\Hash::make($validated['password']); } + // 角色初始化與克隆邏輯 + $role_to_assign = $validated['role']; + $target_company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id; + + if ($target_company_id && $roleObj && !$roleObj->is_system && $roleObj->company_id === null) { + // 檢查該公司是否已有名為「管理員」的角色 + $existingRole = \App\Models\System\Role::where('company_id', $target_company_id) + ->where('name', '管理員') + ->first(); + + if (!$existingRole) { + $clonedRole = \App\Models\System\Role::query()->create([ + 'name' => '管理員', + 'guard_name' => 'web', + 'company_id' => $target_company_id, + 'is_system' => false, + ]); + $clonedRole->syncPermissions($roleObj->permissions); + $role_to_assign = '管理員'; + } else { + $role_to_assign = '管理員'; + } + } + $user->update($updateData); // 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色 if ($user->id === auth()->id() && auth()->user()->isSystemAdmin()) { $user->syncRoles(['super-admin']); } else { - $user->syncRoles([$validated['role']]); + $user->syncRoles([$role_to_assign]); } return redirect()->back()->with('success', __('Account updated successfully.')); @@ -292,6 +411,12 @@ class PermissionController extends Controller return redirect()->back()->with('error', __('You cannot delete your own account.')); } + // 為了解決軟刪除導致的唯一索引佔用問題,刪除前先重命名唯一欄位 + $timestamp = now()->getTimestamp(); + $user->username = $user->username . '.deleted.' . $timestamp; + $user->email = $user->email . '.deleted.' . $timestamp; + $user->save(); + $user->delete(); return redirect()->back()->with('success', __('Account deleted successfully.')); diff --git a/lang/en.json b/lang/en.json index e75ffbf..2150932 100644 --- a/lang/en.json +++ b/lang/en.json @@ -229,6 +229,7 @@ "Enter login ID": "Enter login ID", "Min 8 characters": "Min 8 characters", "Admin display name": "Admin display name", + "Initial Role": "Initial Role", "Contact & Details": "Contact & Details", "e.g. Taiwan Star": "e.g. Taiwan Star", "e.g. TWSTAR": "e.g. TWSTAR", diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 6e56b9c..c836a70 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -229,6 +229,7 @@ "Enter login ID": "請輸入登入帳號", "Min 8 characters": "至少 8 個字元", "Admin display name": "管理員顯示名稱", + "Initial Role": "初始角色", "Contact & Details": "聯絡資訊與詳情", "e.g. Taiwan Star": "例如:台灣之星", "e.g. TWSTAR": "例如:TWSTAR", diff --git a/resources/css/app.css b/resources/css/app.css index 9d39296..3023d77 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -61,6 +61,66 @@ animation: fadeUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; } + /* Additional Loading Animations */ + @keyframes fadeInRight { + from { + opacity: 0; + transform: translateX(-10px); + } + to { + opacity: 1; + transform: translateX(0); + } + } + + .animate-fade-in-right { + animation: fadeInRight 0.8s ease-out forwards; + } + + @keyframes trickle { + 0% { width: 0%; opacity: 1; } + 20% { width: 30%; } + 50% { width: 70%; } + 80% { width: 90%; } + 95% { width: 95%; } + 100% { width: 95%; } + } + + .animate-trickle { + animation: trickle 10s cubic-bezier(0.1, 0.5, 0.5, 1) forwards; + } + + /* Top Progress Bar (Trickle Style) */ + .top-loading-bar { + @apply fixed top-0 left-0 right-0 h-0.5 z-[99999] bg-gradient-to-r from-cyan-500 to-blue-500 opacity-0 transition-opacity duration-300; + width: 0%; + } + + .top-loading-bar.loading { + @apply opacity-100; + animation: trickle 12s cubic-bezier(0.1, 0.5, 0.5, 1) forwards; + } + + @keyframes loadingPulse { + 0% { opacity: 1; } + 50% { opacity: 0.6; } + 100% { opacity: 1; } + } + + /* Skeleton Loading Utilities (Option C) */ + .skeleton { + @apply bg-slate-100 dark:bg-slate-800 animate-pulse-subtle rounded-lg overflow-hidden relative border-none !text-transparent selection:bg-transparent pointer-events-none; + } + + @keyframes pulse-subtle { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + .animate-pulse-subtle { + animation: pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + /* Custom Scrollbar - Minimal & Elegant */ ::-webkit-scrollbar { width: 6px; diff --git a/resources/views/admin/companies/index.blade.php b/resources/views/admin/companies/index.blade.php index d9f702b..de6137c 100644 --- a/resources/views/admin/companies/index.blade.php +++ b/resources/views/admin/companies/index.blade.php @@ -322,9 +322,21 @@ placeholder="{{ __('Min 8 characters') }}"> -
- - +
+
+ + +
+
+ + +
diff --git a/resources/views/admin/data-config/accounts.blade.php b/resources/views/admin/data-config/accounts.blade.php index 61f92fa..022b047 100644 --- a/resources/views/admin/data-config/accounts.blade.php +++ b/resources/views/admin/data-config/accounts.blade.php @@ -7,28 +7,53 @@ @section('content')
user()->company_id }}', role: '', status: 1 }; this.showModal = true; + // 預設選取第一個可用的角色 + this.$nextTick(() => { + if (this.filteredRoles.length > 0) { + this.currentUser.role = this.filteredRoles[0].name; + } + }); }, openEditModal(user) { this.editing = true; this.currentUser = { ...user, - role: user.roles && user.roles.length > 0 ? user.roles[0].name : 'user' + company_id: user.company_id || '', + role: user.roles && user.roles.length > 0 ? user.roles[0].name : '' }; this.showModal = true; } @@ -217,61 +242,97 @@
- - + + + @error('name') +

{{ $message }}

+ @enderror
- - + + + @error('username') +

{{ $message }}

+ @enderror
- - + + + @error('email') +

{{ $message }}

+ @enderror
- -
-
- -
-
- - -
-
- - + + @error('phone') +

{{ $message }}

+ @enderror
@if(auth()->user()->isSystemAdmin()) -
+
- @foreach($companies as $company) @endforeach + @error('company_id') +

{{ $message }}

+ @enderror
@endif +
+
+ + + @error('role') +

{{ $message }}

+ @enderror +
+
+ + + @error('status') +

{{ $message }}

+ @enderror +
+
+
- + + @error('password') +

{{ $message }}

+ @enderror
diff --git a/resources/views/admin/permission/roles.blade.php b/resources/views/admin/permission/roles.blade.php index efcdf23..aeba1fb 100644 --- a/resources/views/admin/permission/roles.blade.php +++ b/resources/views/admin/permission/roles.blade.php @@ -7,13 +7,13 @@ @section('content')
@csrf +
@@ -167,7 +168,13 @@
- + + @error('name') +

+ + {{ $message }} +

+ @enderror