diff --git a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php index b007952..a4fb477 100644 --- a/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php +++ b/app/Http/Controllers/Admin/BasicSettings/MachineSettingController.php @@ -75,8 +75,10 @@ class MachineSettingController extends AdminController break; case 'permissions': + // 套用層級可見範圍:系統管理員看全部;租戶僅看可見範圍內帳號(避免跨租戶洩漏)。 $userQuery = \App\Models\System\User::query() ->where('is_admin', true) + ->visibleTo($currentUser) ->with(['company', 'machines']); if ($search) { $userQuery->where(function($q) use ($search) { @@ -121,6 +123,7 @@ class MachineSettingController extends AdminController $userQuery = \App\Models\System\User::query() ->where('is_admin', true) + ->visibleTo($currentUser) ->with(['company', 'machines']); $users_list = $userQuery->latest()->paginate($per_page)->withQueryString(); diff --git a/app/Http/Controllers/Admin/Machine/MachinePermissionController.php b/app/Http/Controllers/Admin/Machine/MachinePermissionController.php index bdc80b3..fbe9d33 100644 --- a/app/Http/Controllers/Admin/Machine/MachinePermissionController.php +++ b/app/Http/Controllers/Admin/Machine/MachinePermissionController.php @@ -32,10 +32,9 @@ class MachinePermissionController extends AdminController }]) ->whereNotNull('company_id'); - // 非系統管理員僅能看到同公司的帳號 (因 User Model 排除 TenantScoped 全域過濾,需手動注入) - if (!$currentUser->isSystemAdmin()) { - $userQuery->where('company_id', $currentUser->company_id); - } elseif ($company_id) { + // 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。 + $userQuery->visibleTo($currentUser); + if ($currentUser->isSystemAdmin() && $company_id) { // 系統管理員的篩選邏輯 $userQuery->where('company_id', $company_id); } @@ -54,6 +53,31 @@ class MachinePermissionController extends AdminController return view('admin.machines.permissions', compact('users_list', 'companies')); } + /** + * 操作者「可授權」的機台 ID 集合(授權子集約束): + * - 系統管理員:指定公司(或全部)的所有機台 + * - 主帳號:同公司全部機台 + * - 子帳號:僅自己被授權的機台(machine_user) + */ + private function assignableMachineIds(User $operator, ?int $companyId): \Illuminate\Support\Collection + { + // 目標帳號無所屬公司(如系統管理員帳號)時,沒有可授權的公司機台。 + if (is_null($companyId)) { + return collect(); + } + + if ($operator->isSystemAdmin() || $operator->is_admin) { + return Machine::withoutGlobalScope('machine_access') + ->where('company_id', $companyId) + ->pluck('id'); + } + + return $operator->machines() + ->withoutGlobalScope('machine_access') + ->where('machines.company_id', $companyId) + ->pluck('machines.id'); + } + /** * AJAX: 取得特定帳號的機台分配狀態 */ @@ -61,17 +85,18 @@ class MachinePermissionController extends AdminController { $currentUser = auth()->user(); - // 安全檢查:只能操作自己公司的帳號(除非是系統管理員) - if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) { + // 層級越權防護:只能操作可管轄範圍內的帳號。 + if (!$currentUser->canManageAccount($user)) { return response()->json(['error' => 'Unauthorized'], 403); } - // 取得該使用者所屬公司之所有機台 (忽略個別帳號的 machine_access 限制,以公司為單位顯示) + // 可授權機台清單限縮為「操作者本身可授權」的機台子集,避免把自己沒有的機台授權出去。 + $assignableIds = $this->assignableMachineIds($currentUser, $user->company_id); $machines = Machine::withoutGlobalScope('machine_access') - ->where('company_id', $user->company_id) + ->whereIn('id', $assignableIds) ->get(['id', 'name', 'serial_no']); - - $assignedIds = $user->machines()->pluck('machines.id')->toArray(); + + $assignedIds = $user->machines()->withoutGlobalScope('machine_access')->pluck('machines.id')->toArray(); return response()->json([ 'user' => $user, @@ -87,8 +112,8 @@ class MachinePermissionController extends AdminController { $currentUser = auth()->user(); - // 安全檢查 - if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) { + // 層級越權防護:只能操作可管轄範圍內的帳號。 + if (!$currentUser->canManageAccount($user)) { return response()->json(['error' => 'Unauthorized'], 403); } @@ -97,20 +122,27 @@ class MachinePermissionController extends AdminController 'machine_ids.*' => 'exists:machines,id' ]); - // 加固驗證:確保所有機台 ID 都屬於該使用者的公司 (使用 withoutGlobalScope 避免管理員自身權限影響驗證邏輯) - if ($request->has('machine_ids')) { - $machineIds = array_unique($request->machine_ids); - $validCount = Machine::withoutGlobalScope('machine_access') - ->where('company_id', $user->company_id) - ->whereIn('id', $machineIds) - ->count(); - - if ($validCount !== count($machineIds)) { - return response()->json(['error' => 'Invalid machine IDs provided.'], 422); - } + // 授權子集約束:被指派的機台必須是「操作者本身可授權機台」的子集, + // 既確保同公司,也避免子帳號把自己沒被授權的機台授權給他人。 + $assignableIds = $this->assignableMachineIds($currentUser, $user->company_id); + $submittedIds = collect($request->machine_ids ?? [])->map(fn ($id) => (int) $id)->unique(); + if ($submittedIds->diff($assignableIds)->isNotEmpty()) { + return response()->json(['error' => 'Invalid machine IDs provided.'], 422); } - $user->machines()->sync($request->machine_ids ?? []); + // 只在「操作者可授權子集」範圍內做增刪;範圍外的既有授權一律保留,避免覆蓋式 sync 誤刪 + // 操作者看不到(不在其可授權子集內)的機台。明確 detach/attach 以避開 machine_access + // 全域 scope 對 sync 取「現有附加」造成的干擾(該 scope 依登入者過濾 Machine 查詢)。 + $existingIds = $user->machines()->withoutGlobalScope('machine_access')->pluck('machines.id'); + $currentInScope = $existingIds->intersect($assignableIds); + $toAttach = $submittedIds->diff($currentInScope); + $toDetach = $currentInScope->diff($submittedIds); + if ($toDetach->isNotEmpty()) { + $user->machines()->detach($toDetach->all()); + } + if ($toAttach->isNotEmpty()) { + $user->machines()->attach($toAttach->all()); + } $message = __('Machine permissions updated successfully.'); session()->flash('success', $message); diff --git a/app/Http/Controllers/Admin/PermissionController.php b/app/Http/Controllers/Admin/PermissionController.php index a590113..1fe5d3a 100644 --- a/app/Http/Controllers/Admin/PermissionController.php +++ b/app/Http/Controllers/Admin/PermissionController.php @@ -14,10 +14,8 @@ class PermissionController extends Controller $user = auth()->user(); $query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']); - // 租戶隔離:租戶只能看到自己公司的角色 - if (!$user->isSystemAdmin()) { - $query->where('company_id', $user->company_id); - } + // 層級隔離:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己建立的角色。 + $query->visibleTo($user); // 搜尋:角色名稱(支援 roles_search 命名空間 與舊版 search) $search = request()->input('roles_search', request()->input('search')); @@ -88,6 +86,11 @@ class PermissionController extends Controller $role = \App\Models\System\Role::findOrFail($id); $user = auth()->user(); + // 層級隔離:子帳號只能編輯自己建立的角色,不可開啟上層/旁線建立的角色。 + if (!$role->canBeManagedBy($user)) { + return redirect()->back()->with('error', __('You do not have permission to manage this role.')); + } + // 權限遞迴約束與分組邏輯由 getFilteredPermissions 處理 $all_permissions = $this->getFilteredPermissions($user); @@ -126,6 +129,7 @@ class PermissionController extends Controller 'name' => $validated['name'], 'guard_name' => 'web', 'company_id' => $is_system ? null : auth()->user()->company_id, + 'created_by' => auth()->id(), 'is_system' => $is_system, ]); @@ -182,6 +186,11 @@ class PermissionController extends Controller return redirect()->back()->with('error', __('System roles cannot be modified by tenant administrators.')); } + // 層級隔離:子帳號只能修改自己建立的角色。 + if (!$role->canBeManagedBy(auth()->user())) { + return redirect()->back()->with('error', __('You do not have permission to manage this role.')); + } + $is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system; $updateData = [ @@ -223,10 +232,20 @@ class PermissionController extends Controller return redirect()->back()->with('error', __('The Super Admin role cannot be deleted.')); } + // 公司主帳號角色保護:比照 super-admin,公司層級的「管理員」角色為該公司最高權限角色,不可刪除。 + if ($role->is_company_admin) { + return redirect()->back()->with('error', __('The company admin role cannot be deleted.')); + } + if (!auth()->user()->isSystemAdmin() && $role->is_system) { return redirect()->back()->with('error', __('System roles cannot be deleted by tenant administrators.')); } + // 層級隔離:子帳號只能刪除自己建立的角色。 + if (!$role->canBeManagedBy(auth()->user())) { + return redirect()->back()->with('error', __('You do not have permission to manage this role.')); + } + if ($role->users()->count() > 0) { return redirect()->back()->with('error', __('Cannot delete role with active users.')); } @@ -251,10 +270,8 @@ class PermissionController extends Controller $currentUserRoleIds = $user->roles->pluck('id')->toArray(); // ── 帳號列表 ────────────────────────────────────────────────── - $usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines']); - if (!$user->isSystemAdmin()) { - $usersQuery->where('company_id', $user->company_id); - } + // 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層。 + $usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines'])->visibleTo($user); if ($search = $request->input('accounts_search', $request->input('search'))) { $usersQuery->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") @@ -269,22 +286,24 @@ class PermissionController extends Controller $accounts_per_page = $request->input('accounts_per_page', 10); $users = $usersQuery->latest()->paginate($accounts_per_page, ['*'], 'accounts_page')->withQueryString(); - // Modal 用的角色選單(不分頁) - $rolesForSelect = \App\Models\System\Role::query(); - if (!$user->isSystemAdmin()) { - $rolesForSelect->forCompany($user->company_id); - } + // Modal 用的角色選單(不分頁)。層級隔離:子帳號僅見自己建立的角色(與角色清單一致)。 + // 預先載入 permissions 以避免下方子集過濾造成 N+1。 + $rolesForSelect = \App\Models\System\Role::query()->with('permissions')->visibleTo($user); $roles = $rolesForSelect->get(); + // 子集過濾:租戶只能指派「權限為自身子集」的角色(與 store/update 的後端 guard 一致,避免下拉出現越權選項)。 + if (!$user->isSystemAdmin()) { + $operatorPerms = $user->getAllPermissions()->pluck('name'); + $roles = $roles->filter( + fn($r) => collect($r->getPermissionNames())->diff($operatorPerms)->isEmpty() + )->values(); + } // ── 角色列表 (僅 data-config Tab 版需要) ───────────────────── $paginated_roles = collect(); $all_permissions = collect(); if ($isSubAccountRoute) { - $rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']); - if (!$user->isSystemAdmin()) { - $rolesQuery->where('company_id', $user->company_id); - } + $rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company'])->visibleTo($user); if ($search = $request->input('roles_search')) { $rolesQuery->where('name', 'like', "%{$search}%"); } @@ -328,6 +347,12 @@ class PermissionController extends Controller 'phone' => 'nullable|string|max:20', ]); + // 深度上限:level >= MAX_LEVEL 的子帳號不可再建立下層(系統管理員不受限)。 + // 此為後端硬擋,獨立於權限判斷(即使持有子帳號管理權限也不得突破層級)。 + if (!auth()->user()->canCreateSubAccount()) { + return redirect()->back()->with('error', __('Sub-accounts at this level cannot create further sub-accounts.')); + } + $company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id; // 查找角色:優先尋找該公司的角色,若無則尋找全域範本 @@ -374,6 +399,7 @@ class PermissionController extends Controller 'guard_name' => 'web', 'company_id' => $company_id, 'is_system' => false, + 'is_company_admin' => true, ]); $newRole->syncPermissions($role->getPermissionNames()); $role = $newRole; @@ -383,6 +409,39 @@ class PermissionController extends Controller } } + // 角色指派子集驗證:租戶只能指派「權限為自身子集」的角色,避免透過指派高權限角色造成權限提升。 + if (!auth()->user()->isSystemAdmin()) { + $operatorPerms = auth()->user()->getAllPermissions()->pluck('name'); + if (collect($role->getPermissionNames())->diff($operatorPerms)->isNotEmpty()) { + return redirect()->back()->with('error', __('You cannot assign a role with permissions you do not possess.')); + } + } + + // 主帳號唯一性:一家公司只在「尚無主帳號」時,由系統管理員建立的第一個帳號自動成為主帳號 (is_admin)。 + // 之後建立的帳號(含子帳號自建)一律 is_admin=false,確保每家公司恰有一個主帳號。 + $creator = auth()->user(); + $companyHasAdmin = $company_id + ? \App\Models\System\User::where('company_id', $company_id)->where('is_admin', true)->exists() + : false; + $isMainAccount = $creator->isSystemAdmin() && !empty($company_id) && !$companyHasAdmin; + + // 樹狀層級歸屬: + // - 主帳號:level 0、parent_id null。 + // - 系統管理員建立的非主帳號:掛在該公司主帳號下、level 1。 + // - 租戶自建:掛在建立者下、level = 建立者 level + 1。 + if ($isMainAccount) { + $parentId = null; + $level = 0; + } elseif ($creator->isSystemAdmin()) { + $parentId = $company_id + ? \App\Models\System\User::where('company_id', $company_id)->where('is_admin', true)->value('id') + : null; + $level = $parentId ? 1 : 0; + } else { + $parentId = $creator->id; + $level = $creator->level + 1; + } + $user = \App\Models\System\User::create([ 'name' => $validated['name'], 'username' => $validated['username'], @@ -390,8 +449,10 @@ class PermissionController extends Controller 'password' => \Illuminate\Support\Facades\Hash::make($validated['password']), 'status' => $validated['status'], 'company_id' => $company_id, + 'parent_id' => $parentId, + 'level' => $level, 'phone' => $validated['phone'] ?? null, - 'is_admin' => (auth()->user()->isSystemAdmin() && !empty($validated['company_id'])), + 'is_admin' => $isMainAccount, ]); $user->assignRole($role); @@ -410,6 +471,11 @@ class PermissionController extends Controller return redirect()->back()->with('error', __('System super admin accounts can only be modified by other super admins.')); } + // 層級越權防護:租戶只能管理可管轄範圍內的帳號(主帳號=同公司全部;子帳號=自己直接建立的下層)。 + if (!auth()->user()->canManageAccount($user)) { + return redirect()->back()->with('error', __('You do not have permission to manage this account.')); + } + $validated = $request->validate([ 'name' => 'required|string|max:255', 'username' => 'required|string|max:255|unique:users,username,' . $id, @@ -500,7 +566,7 @@ class PermissionController extends Controller 'guard_name' => 'web', 'company_id' => $target_company_id, 'is_system' => false, - 'is_admin' => true, + 'is_company_admin' => true, ]); $newRole->syncPermissions($roleObj->getPermissionNames()); $roleObj = $newRole; @@ -509,6 +575,14 @@ class PermissionController extends Controller } } + // 角色指派子集驗證:租戶只能指派「權限為自身子集」的角色,避免權限提升。 + if (!auth()->user()->isSystemAdmin()) { + $operatorPerms = auth()->user()->getAllPermissions()->pluck('name'); + if (collect($roleObj->getPermissionNames())->diff($operatorPerms)->isNotEmpty()) { + return redirect()->back()->with('error', __('You cannot assign a role with permissions you do not possess.')); + } + } + $user->update($updateData); // 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色 @@ -527,15 +601,31 @@ class PermissionController extends Controller public function destroyAccount($id) { $user = \App\Models\System\User::findOrFail($id); - + if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) { return redirect()->back()->with('error', __('System super admin accounts can only be deleted by other super admins.')); } + // 層級越權防護:租戶只能刪除可管轄範圍內的帳號。 + if (!auth()->user()->canManageAccount($user)) { + return redirect()->back()->with('error', __('You do not have permission to manage this account.')); + } + if ($user->id === auth()->id()) { return redirect()->back()->with('error', __('You cannot delete your own account.')); } + // 主帳號保護:若該帳號是公司唯一主帳號,且公司仍有其他帳號,禁止刪除(須先轉移主帳號身分), + // 以維持「有帳號的公司恰有一個主帳號」的不變量。公司僅剩此主帳號時允許刪除(公司歸零)。 + if ($user->is_admin && $user->company_id) { + $hasOtherAccounts = \App\Models\System\User::where('company_id', $user->company_id) + ->where('id', '!=', $user->id) + ->exists(); + if ($hasOtherAccounts) { + return redirect()->back()->with('error', __('Cannot delete the company main account while other accounts exist. Please transfer the main account role first.')); + } + } + // 為了解決軟刪除導致的唯一索引佔用問題,刪除前先重命名唯一欄位 $timestamp = now()->getTimestamp(); $user->username = $user->username . '.deleted.' . $timestamp; @@ -556,6 +646,11 @@ class PermissionController extends Controller return back()->with('error', __('Only Super Admins can change other Super Admin status.')); } + // 層級越權防護:租戶只能切換可管轄範圍內的帳號狀態。 + if (!auth()->user()->canManageAccount($user)) { + return back()->with('error', __('You do not have permission to manage this account.')); + } + $user->status = $user->status ? 0 : 1; $user->save(); diff --git a/app/Http/Controllers/Admin/WarehouseController.php b/app/Http/Controllers/Admin/WarehouseController.php index 5c6411e..d467530 100644 --- a/app/Http/Controllers/Admin/WarehouseController.php +++ b/app/Http/Controllers/Admin/WarehouseController.php @@ -840,10 +840,9 @@ class WarehouseController extends Controller $machines = $applyCompanyFilter(Machine::query())->orderBy('name')->get(['id', 'name', 'serial_no']); $products = $applyCompanyFilter(Product::with('translations'))->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']); - $userQuery = \App\Models\System\User::where('status', 1)->orderBy('name'); - if (!$isSystemAdmin) { - $userQuery->where('company_id', $currentUser->company_id); - } elseif ($companyId !== '') { + // 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。 + $userQuery = \App\Models\System\User::where('status', 1)->orderBy('name')->visibleTo($currentUser); + if ($isSystemAdmin && $companyId !== '') { $userQuery->where('company_id', $companyId); } $users = $userQuery->get(['id', 'name']); diff --git a/app/Models/System/Role.php b/app/Models/System/Role.php index 4c539c0..b1362d1 100644 --- a/app/Models/System/Role.php +++ b/app/Models/System/Role.php @@ -10,11 +10,14 @@ class Role extends SpatieRole 'name', 'guard_name', 'company_id', + 'created_by', 'is_system', + 'is_company_admin', ]; protected $casts = [ 'is_system' => 'boolean', + 'is_company_admin' => 'boolean', ]; /** @@ -25,6 +28,14 @@ class Role extends SpatieRole return $this->belongsTo(Company::class); } + /** + * 建立者帳號。 + */ + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + /** * Scope a query to only include roles for a specific company or system roles. */ @@ -35,4 +46,43 @@ class Role extends SpatieRole ->orWhereNull('company_id'); }); } + + /** + * 查詢範圍:限制為指定使用者「可見」的角色(鏡像 User::scopeVisibleTo)。 + * - 系統管理員:全部 + * - 主帳號:同公司全部 + * - 子帳號:僅自己建立的角色 + */ + public function scopeVisibleTo($query, User $viewer) + { + if ($viewer->isSystemAdmin()) { + return $query; + } + $query->where($this->getTable() . '.company_id', $viewer->company_id); + if (!$viewer->is_admin) { + $query->where($this->getTable() . '.created_by', $viewer->id); + } + return $query; + } + + /** + * 操作者是否有權管理(編輯/刪除)此角色。 + * - 系統管理員:全部 + * - 跨公司:禁止 + * - 主帳號:同公司全部 + * - 子帳號:僅自己建立的角色 + */ + public function canBeManagedBy(User $user): bool + { + if ($user->isSystemAdmin()) { + return true; + } + if ($this->company_id !== $user->company_id) { + return false; + } + if ($user->is_admin) { + return true; + } + return $this->created_by === $user->id; + } } diff --git a/app/Models/System/User.php b/app/Models/System/User.php index 7c0fac3..56a1e17 100644 --- a/app/Models/System/User.php +++ b/app/Models/System/User.php @@ -23,6 +23,8 @@ class User extends Authenticatable */ protected $fillable = [ 'company_id', + 'parent_id', + 'level', 'username', 'name', 'email', @@ -53,8 +55,14 @@ class User extends Authenticatable 'email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean', + 'level' => 'integer', ]; + /** + * 帳號層級上限:子帳號最多兩層(主帳號 level 0 → 子 level 1 → 孫 level 2,level 2 不可再建)。 + */ + public const MAX_LEVEL = 2; + /** * Get the login logs for the user. */ @@ -95,6 +103,85 @@ class User extends Authenticatable return !is_null($this->company_id); } + /** + * 上層(建立者)帳號。 + */ + public function parent() + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** + * 直接建立的下層帳號。 + */ + public function children() + { + return $this->hasMany(self::class, 'parent_id'); + } + + /** + * 是否為該公司主帳號(租戶層級且 is_admin)。 + */ + public function isMainAccount(): bool + { + return $this->isTenant() && $this->is_admin; + } + + /** + * 是否為子帳號(租戶層級且非主帳號)。 + */ + public function isSubAccount(): bool + { + return $this->isTenant() && !$this->is_admin; + } + + /** + * 是否還能再建立下層子帳號(深度上限約束,系統管理員不受限)。 + */ + public function canCreateSubAccount(): bool + { + return $this->isSystemAdmin() || $this->level < self::MAX_LEVEL; + } + + /** + * 查詢範圍:限制為指定使用者「可見」的帳號。 + * - 系統管理員:全部 + * - 主帳號:同公司全部(安全網) + * - 子帳號:僅自己直接建立的下層帳號 + */ + public function scopeVisibleTo($query, self $viewer) + { + if ($viewer->isSystemAdmin()) { + return $query; + } + $query->where($this->getTable() . '.company_id', $viewer->company_id); + if (!$viewer->is_admin) { + $query->where($this->getTable() . '.parent_id', $viewer->id); + } + return $query; + } + + /** + * 操作者是否有權管理(編輯/刪除/停用)指定帳號。 + * - 系統管理員:全部 + * - 跨公司:禁止 + * - 主帳號:同公司全部 + * - 子帳號:僅自己直接建立的下層帳號 + */ + public function canManageAccount(self $target): bool + { + if ($this->isSystemAdmin()) { + return true; + } + if ($target->company_id !== $this->company_id) { + return false; + } + if ($this->is_admin) { + return true; + } + return $target->parent_id === $this->id; + } + /** * Get the URL for the user's avatar. */ diff --git a/database/migrations/2026_06_25_100000_add_is_company_admin_to_roles_table.php b/database/migrations/2026_06_25_100000_add_is_company_admin_to_roles_table.php new file mode 100644 index 0000000..fd27bd1 --- /dev/null +++ b/database/migrations/2026_06_25_100000_add_is_company_admin_to_roles_table.php @@ -0,0 +1,42 @@ +boolean('is_company_admin')->default(false)->after('is_system'); + }); + } + + // Backfill:既有的公司層級「管理員」角色標記為 is_company_admin = true + DB::table('roles') + ->whereNotNull('company_id') + ->where('name', '管理員') + ->update(['is_company_admin' => true]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('roles', 'is_company_admin')) { + Schema::table('roles', function (Blueprint $table) { + $table->dropColumn('is_company_admin'); + }); + } + } +}; diff --git a/database/migrations/2026_06_25_100100_add_parent_id_and_level_to_users_table.php b/database/migrations/2026_06_25_100100_add_parent_id_and_level_to_users_table.php new file mode 100644 index 0000000..e83fea2 --- /dev/null +++ b/database/migrations/2026_06_25_100100_add_parent_id_and_level_to_users_table.php @@ -0,0 +1,113 @@ +unsignedBigInteger('parent_id')->nullable()->after('company_id'); + $table->unsignedTinyInteger('level')->default(0)->after('parent_id'); + $table->foreign('parent_id')->references('id')->on('users')->nullOnDelete(); + $table->index('parent_id'); + }); + + $companyIds = DB::table('companies')->pluck('id'); + + // 1. is_admin 正規化 + foreach ($companyIds as $cid) { + // a. 軟刪除帳號一律不得為主帳號 + DB::table('users') + ->where('company_id', $cid) + ->whereNotNull('deleted_at') + ->where('is_admin', true) + ->update(['is_admin' => false]); + + // b. 在「未軟刪除」帳號中確保恰有一個主帳號 + $admins = DB::table('users') + ->where('company_id', $cid) + ->whereNull('deleted_at') + ->where('is_admin', true) + ->orderBy('id') + ->pluck('id'); + + if ($admins->isEmpty()) { + // 無主帳號 → 將最早建立的帳號補為主帳號 + $firstId = DB::table('users') + ->where('company_id', $cid) + ->whereNull('deleted_at') + ->orderBy('id') + ->value('id'); + if ($firstId) { + DB::table('users')->where('id', $firstId)->update(['is_admin' => true]); + } + } elseif ($admins->count() > 1) { + // 多主帳號 → 只保留最早一個,其餘降為一般帳號 + DB::table('users') + ->whereIn('id', $admins->slice(1)->values()) + ->update(['is_admin' => false]); + } + } + + // 2. 回填 parent_id / level + foreach ($companyIds as $cid) { + // 主帳號必須取自「未軟刪除」帳號 + $mainId = DB::table('users') + ->where('company_id', $cid) + ->whereNull('deleted_at') + ->where('is_admin', true) + ->value('id'); + + if (!$mainId) { + continue; // 空公司,略過 + } + + // 主帳號 + DB::table('users')->where('id', $mainId)->update(['parent_id' => null, 'level' => 0]); + // 其餘帳號(含軟刪除)掛在主帳號下,level=1 + DB::table('users') + ->where('company_id', $cid) + ->where('id', '!=', $mainId) + ->update(['parent_id' => $mainId, 'level' => 1]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // 防禦式:外鍵 / 索引 / 欄位可能因 DDL 半途失敗而部分不存在,逐項忽略不存在的情況。 + try { + Schema::table('users', fn (Blueprint $t) => $t->dropForeign(['parent_id'])); + } catch (\Throwable $e) { + // 外鍵已不存在,略過 + } + try { + Schema::table('users', fn (Blueprint $t) => $t->dropIndex('users_parent_id_index')); + } catch (\Throwable $e) { + // 索引已不存在,略過 + } + Schema::table('users', function (Blueprint $table) { + $cols = array_values(array_filter( + ['parent_id', 'level'], + fn ($c) => Schema::hasColumn('users', $c) + )); + if ($cols) { + $table->dropColumn($cols); + } + }); + } +}; diff --git a/database/migrations/2026_06_25_100200_add_created_by_to_roles_table.php b/database/migrations/2026_06_25_100200_add_created_by_to_roles_table.php new file mode 100644 index 0000000..acaee16 --- /dev/null +++ b/database/migrations/2026_06_25_100200_add_created_by_to_roles_table.php @@ -0,0 +1,60 @@ +unsignedBigInteger('created_by')->nullable()->after('company_id'); + $table->foreign('created_by')->references('id')->on('users')->nullOnDelete(); + $table->index('created_by'); + }); + + // 既有公司角色歸屬該公司主帳號(未軟刪除的 is_admin) + $companyIds = DB::table('roles')->whereNotNull('company_id')->distinct()->pluck('company_id'); + foreach ($companyIds as $cid) { + $mainId = DB::table('users') + ->where('company_id', $cid) + ->whereNull('deleted_at') + ->where('is_admin', true) + ->value('id'); + if ($mainId) { + DB::table('roles') + ->where('company_id', $cid) + ->whereNull('created_by') + ->update(['created_by' => $mainId]); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + try { + Schema::table('roles', fn (Blueprint $t) => $t->dropForeign(['created_by'])); + } catch (\Throwable $e) { + // 外鍵已不存在,略過 + } + try { + Schema::table('roles', fn (Blueprint $t) => $t->dropIndex('roles_created_by_index')); + } catch (\Throwable $e) { + // 索引已不存在,略過 + } + if (Schema::hasColumn('roles', 'created_by')) { + Schema::table('roles', fn (Blueprint $t) => $t->dropColumn('created_by')); + } + } +}; diff --git a/lang/en.json b/lang/en.json index 755cff3..108b4ee 100644 --- a/lang/en.json +++ b/lang/en.json @@ -275,6 +275,7 @@ "Cannot delete company with active accounts.": "Cannot delete company with active accounts.", "Cannot delete model that is currently in use by machines.": "Cannot delete model that is currently in use by machines.", "Cannot delete role with active users.": "Cannot delete role with active users.", + "Cannot delete the company main account while other accounts exist. Please transfer the main account role first.": "Cannot delete the company main account while other accounts exist. Please transfer the main account role first.", "Cannot delete warehouse with existing stock": "Cannot delete warehouse with existing stock", "Cannot match original stored-value transaction": "Cannot match original stored-value transaction", "Card Machine System": "Card Machine System", @@ -2065,6 +2066,7 @@ "Sub Account Management": "Sub Account Management", "Sub Account Roles": "Sub Account Roles", "Sub Accounts": "Sub Accounts", + "Sub-accounts at this level cannot create further sub-accounts.": "Sub-accounts at this level cannot create further sub-accounts.", "Sub-actions": "Sub-actions", "Sub-machine": "Sub-machine", "Sub-machine Status": "Sub-machine Status", @@ -2151,6 +2153,7 @@ "The Super Admin role cannot be deleted.": "The Super Admin role cannot be deleted.", "The Super Admin role is immutable.": "The Super Admin role is immutable.", "The Super Admin role name cannot be modified.": "The Super Admin role name cannot be modified.", + "The company admin role cannot be deleted.": "The company admin role cannot be deleted.", "The image archive contains too many files": "The image archive contains too many files", "The image archive is too large when extracted": "The image archive is too large when extracted", "The image is too large. Please upload an image smaller than 1MB.": "The image is too large. Please upload an image smaller than 1MB.", @@ -2414,9 +2417,12 @@ "Yesterday": "Yesterday", "You can assign or change the personnel handling this order": "You can assign or change the personnel handling this order", "You can select at most :max languages.": "You can select at most :max languages.", + "You cannot assign a role with permissions you do not possess.": "You cannot assign a role with permissions you do not possess.", "You cannot assign permissions you do not possess.": "You cannot assign permissions you do not possess.", "You cannot delete your own account.": "You cannot delete your own account.", "You do not have permission to access replenishment orders": "You do not have permission to access replenishment orders", + "You do not have permission to manage this account.": "You do not have permission to manage this account.", + "You do not have permission to manage this role.": "You do not have permission to manage this role.", "Your account is disabled.": "Your account is disabled.", "Your recent account activity": "Your recent account activity", "[PickupCode] Verification failed: :code": "[PickupCode] Verification failed: :code", diff --git a/lang/ja.json b/lang/ja.json index ca815fd..64003b0 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -275,6 +275,7 @@ "Cannot delete company with active accounts.": "有効なアカウントを持つ顧客は削除できません。", "Cannot delete model that is currently in use by machines.": "機器で使用中のモデルは削除できません。", "Cannot delete role with active users.": "ユーザーが紐付けられているロールは削除できません。", + "Cannot delete the company main account while other accounts exist. Please transfer the main account role first.": "他のアカウントが存在する間は、会社のメインアカウントを削除できません。先にメインアカウントの権限を移譲してください。", "Cannot delete warehouse with existing stock": "在庫がある倉庫は削除できません", "Cannot match original stored-value transaction": "元の電子マネー取引と照合できません", "Card Machine System": "カードリーダーシステム", @@ -2065,6 +2066,7 @@ "Sub Account Management": "サブアカウント管理", "Sub Account Roles": "サブアカウントロール", "Sub Accounts": "サブアカウント", + "Sub-accounts at this level cannot create further sub-accounts.": "このレベルのサブアカウントは、これ以上下位のサブアカウントを作成できません。", "Sub-actions": "サブアクション", "Sub-machine": "サブマシン", "Sub-machine Status": "Sub-machine Status", @@ -2151,6 +2153,7 @@ "The Super Admin role cannot be deleted.": "特権管理者ロールは削除できません。", "The Super Admin role is immutable.": "特権管理者ロールは変更できません。", "The Super Admin role name cannot be modified.": "特権管理者ロールの名前は変更できません。", + "The company admin role cannot be deleted.": "会社管理者ロールは削除できません。", "The image archive contains too many files": "画像アーカイブのファイル数が多すぎます", "The image archive is too large when extracted": "画像アーカイブの展開後のサイズが大きすぎます", "The image is too large. Please upload an image smaller than 1MB.": "画像サイズが大きすぎます。1MB未満の画像をアップロードしてください。", @@ -2414,9 +2417,12 @@ "Yesterday": "昨日", "You can assign or change the personnel handling this order": "この注文を処理する担当者を割り当て、または変更できます", "You can select at most :max languages.": "最大 :max 言語まで選択できます。", + "You cannot assign a role with permissions you do not possess.": "自分が保有していない権限を含むロールは割り当てできません。", "You cannot assign permissions you do not possess.": "自身が持っていない権限を割り当てることはできません。", "You cannot delete your own account.": "自分自身のアカウントは削除できません。", "You do not have permission to access replenishment orders": "補充伝票のアクセス権限がありません", + "You do not have permission to manage this account.": "このアカウントを管理する権限がありません。", + "You do not have permission to manage this role.": "このロールを管理する権限がありません。", "Your account is disabled.": "アカウントが無効化されています。", "Your recent account activity": "最近のアカウントアクティビティ", "[PickupCode] Verification failed: :code": "[受取コード] 検証失敗: :code", diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 8bfb550..6ad0935 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -275,6 +275,7 @@ "Cannot delete company with active accounts.": "無法刪除仍有客用帳號的客戶。", "Cannot delete model that is currently in use by machines.": "無法刪除目前正在被機台使用的型號。", "Cannot delete role with active users.": "無法刪除已有綁定帳號的角色。", + "Cannot delete the company main account while other accounts exist. Please transfer the main account role first.": "公司主帳號為唯一主帳號,仍有其他帳號時不可刪除,請先轉移主帳號身分。", "Cannot delete warehouse with existing stock": "無法刪除仍有庫存的倉庫", "Cannot match original stored-value transaction": "無法比對原始電票交易", "Card Machine System": "刷卡機系統", @@ -2065,6 +2066,7 @@ "Sub Account Management": "子帳號管理", "Sub Account Roles": "子帳號角色", "Sub Accounts": "子帳號", + "Sub-accounts at this level cannot create further sub-accounts.": "此層級的子帳號無法再建立下層子帳號。", "Sub-actions": "子項目", "Sub-machine": "下位機", "Sub-machine Status": "下位機狀態", @@ -2151,6 +2153,7 @@ "The Super Admin role cannot be deleted.": "超級管理員角色不可刪除。", "The Super Admin role is immutable.": "超級管理員角色不可修改。", "The Super Admin role name cannot be modified.": "超級管理員角色的名稱無法修改。", + "The company admin role cannot be deleted.": "公司管理員角色不可刪除。", "The image archive contains too many files": "圖片壓縮檔內的檔案數量過多", "The image archive is too large when extracted": "圖片壓縮檔解壓後的體積過大", "The image is too large. Please upload an image smaller than 1MB.": "圖片檔案太大,請上傳小於 1MB 的圖片。", @@ -2414,9 +2417,12 @@ "Yesterday": "昨日", "You can assign or change the personnel handling this order": "您可以指派或變更處理此訂單的人員", "You can select at most :max languages.": "最多只能選擇 :max 種語系。", + "You cannot assign a role with permissions you do not possess.": "您無法指派含有您本身未持有權限的角色。", "You cannot assign permissions you do not possess.": "您無法指派您自身不具備的權限。", "You cannot delete your own account.": "您無法刪除自己的帳號。", "You do not have permission to access replenishment orders": "您沒有機台補貨單的權限", + "You do not have permission to manage this account.": "您沒有權限管理此帳號。", + "You do not have permission to manage this role.": "您沒有權限管理此角色。", "Your account is disabled.": "您的帳號已被停用。", "Your recent account activity": "最近的帳號活動", "[PickupCode] Verification failed: :code": "[取貨碼] 驗證失敗: :code", diff --git a/tests/Feature/Admin/AccountHierarchyTest.php b/tests/Feature/Admin/AccountHierarchyTest.php new file mode 100644 index 0000000..37e4d8a --- /dev/null +++ b/tests/Feature/Admin/AccountHierarchyTest.php @@ -0,0 +1,157 @@ + $p, 'guard_name' => 'web']); + } + + $superAdmin = Role::create(['name' => 'super-admin', 'company_id' => null, 'is_system' => true, 'guard_name' => 'web']); + $superAdmin->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']); + + Role::create(['name' => '客戶管理員角色模板', 'company_id' => null, 'is_system' => true, 'guard_name' => 'web']) + ->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']); + + $this->company = Company::create(['name' => 'Test Co', 'code' => 'TEST']); + + $this->sysAdmin = User::factory()->create(['company_id' => null]); + $this->sysAdmin->assignRole($superAdmin); + + // 供租戶帳號使用、帶有子帳號管理權限的公司層級角色 + $this->tenantRole = Role::create(['name' => 'tenant-mgr', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']); + $this->tenantRole->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']); + } + + /** 建立一個租戶帳號(直接寫入層級欄位)並賦予可建子帳號的角色 */ + private function makeTenantUser(array $attrs): User + { + $user = User::factory()->create(array_merge(['company_id' => $this->company->id], $attrs)); + $user->assignRole($this->tenantRole); + return $user; + } + + public function test_first_company_account_becomes_main(): void + { + $this->actingAs($this->sysAdmin)->post(route('admin.permission.accounts.store'), [ + 'name' => 'First', 'username' => 'first', 'email' => 'first@test.co', 'password' => 'password123', + 'role' => '客戶管理員角色模板', 'status' => 1, 'company_id' => $this->company->id, + ])->assertSessionHas('success'); + + $u = User::where('username', 'first')->first(); + $this->assertTrue($u->is_admin); + $this->assertSame(0, $u->level); + $this->assertNull($u->parent_id); + } + + public function test_second_company_account_is_not_main_and_is_child(): void + { + $main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]); + + $this->actingAs($this->sysAdmin)->post(route('admin.permission.accounts.store'), [ + 'name' => 'Second', 'username' => 'second', 'email' => 'second@test.co', 'password' => 'password123', + 'role' => 'tenant-mgr', 'status' => 1, 'company_id' => $this->company->id, + ])->assertSessionHas('success'); + + $u = User::where('username', 'second')->first(); + $this->assertFalse($u->is_admin); + $this->assertSame(1, $u->level); + $this->assertSame($main->id, $u->parent_id); + } + + public function test_tenant_admin_creates_sub_account_sets_parent_and_level(): void + { + $main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]); + + $this->actingAs($main)->post(route('admin.data-config.sub-accounts.store'), [ + 'name' => 'Sub', 'username' => 'sub', 'email' => 'sub@test.co', 'password' => 'password123', + 'role' => 'tenant-mgr', 'status' => 1, + ])->assertSessionHas('success'); + + $u = User::where('username', 'sub')->first(); + $this->assertFalse($u->is_admin); + $this->assertSame(1, $u->level); + $this->assertSame($main->id, $u->parent_id); + $this->assertSame($this->company->id, $u->company_id); + } + + public function test_level_two_sub_account_cannot_create_further(): void + { + $deep = $this->makeTenantUser(['is_admin' => false, 'level' => 2, 'parent_id' => null]); + + $this->actingAs($deep)->post(route('admin.data-config.sub-accounts.store'), [ + 'name' => 'TooDeep', 'username' => 'toodeep', 'email' => 'toodeep@test.co', 'password' => 'password123', + 'role' => 'tenant-mgr', 'status' => 1, + ])->assertSessionHas('error'); + + $this->assertDatabaseMissing('users', ['username' => 'toodeep']); + } + + public function test_visible_to_isolates_siblings_and_self(): void + { + $main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]); + $a = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]); + $b = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]); + $c = $this->makeTenantUser(['is_admin' => false, 'level' => 2, 'parent_id' => $a->id]); + + // 子帳號 A 只看得到自己直建的 C,看不到自己、旁線 B、主帳號 + $visibleToA = User::visibleTo($a)->pluck('id')->all(); + $this->assertSame([$c->id], $visibleToA); + + // 主帳號看得到同公司全部 + $visibleToMain = User::visibleTo($main)->pluck('id')->sort()->values()->all(); + $this->assertEquals(collect([$main->id, $a->id, $b->id, $c->id])->sort()->values()->all(), $visibleToMain); + } + + public function test_sub_account_cannot_manage_sibling(): void + { + $main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]); + $a = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]); + $b = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]); + + // A 嘗試刪除旁線 B → 被擋 + $this->actingAs($a)->delete(route('admin.data-config.sub-accounts.destroy', $b->id)) + ->assertSessionHas('error'); + $this->assertDatabaseHas('users', ['id' => $b->id, 'deleted_at' => null]); + + // A 嘗試停用旁線 B → 被擋 + $this->actingAs($a)->patch(route('admin.data-config.sub-accounts.status.toggle', $b->id)) + ->assertSessionHas('error'); + } + + public function test_main_account_can_manage_company_sub_account(): void + { + $main = $this->makeTenantUser(['is_admin' => true, 'level' => 0, 'parent_id' => null]); + $a = $this->makeTenantUser(['is_admin' => false, 'level' => 1, 'parent_id' => $main->id]); + + // 主帳號可停用同公司子帳號 + $this->actingAs($main)->patch(route('admin.data-config.sub-accounts.status.toggle', $a->id)) + ->assertSessionHas('success'); + } +} diff --git a/tests/Feature/Admin/AccountSubsetGuardTest.php b/tests/Feature/Admin/AccountSubsetGuardTest.php new file mode 100644 index 0000000..39955fc --- /dev/null +++ b/tests/Feature/Admin/AccountSubsetGuardTest.php @@ -0,0 +1,160 @@ + $p, 'guard_name' => 'web']); + } + + $this->company = Company::create(['name' => 'Test Co', 'code' => 'TEST']); + + // 主帳號角色:僅持有兩個權限(不含 extra.high.perm) + $mainRole = Role::create(['name' => 'tenant-mgr', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']); + $mainRole->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts']); + + $this->main = User::factory()->create([ + 'company_id' => $this->company->id, 'is_admin' => true, 'level' => 0, 'parent_id' => null, + ]); + $this->main->assignRole($mainRole); + } + + // ── 角色指派子集 ───────────────────────────────────────────── + + public function test_operator_cannot_assign_role_exceeding_own_permissions(): void + { + // 高權限角色:多出操作者沒有的 extra.high.perm + $highRole = Role::create(['name' => 'high-role', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']); + $highRole->syncPermissions(['menu.permissions.accounts', 'menu.data-config.sub-accounts', 'extra.high.perm']); + + $this->actingAs($this->main)->post(route('admin.data-config.sub-accounts.store'), [ + 'name' => 'X', 'username' => 'escalated', 'email' => 'e@test.co', 'password' => 'password123', + 'role' => 'high-role', 'status' => 1, + ])->assertSessionHas('error'); + + $this->assertDatabaseMissing('users', ['username' => 'escalated']); + } + + public function test_operator_can_assign_role_within_own_permissions(): void + { + $this->actingAs($this->main)->post(route('admin.data-config.sub-accounts.store'), [ + 'name' => 'X', 'username' => 'ok_sub', 'email' => 'ok@test.co', 'password' => 'password123', + 'role' => 'tenant-mgr', 'status' => 1, + ])->assertSessionHas('success'); + + $this->assertDatabaseHas('users', ['username' => 'ok_sub']); + } + + // ── 機台授權子集 ───────────────────────────────────────────── + + public function test_operator_cannot_grant_machine_they_are_not_assigned(): void + { + $operator = User::factory()->create([ + 'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id, + ]); + $target = User::factory()->create([ + 'company_id' => $this->company->id, 'is_admin' => false, 'level' => 2, 'parent_id' => $operator->id, + ]); + + $m1 = Machine::factory()->create(['company_id' => $this->company->id]); + $m2 = Machine::factory()->create(['company_id' => $this->company->id]); + $operator->machines()->attach($m1->id); // 操作者只被授權 m1 + + // 嘗試把自己沒有的 m2 授權給 target → 422 + $this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $target->id), [ + 'machine_ids' => [$m1->id, $m2->id], + ])->assertStatus(422); + + // 只授權自己有的 m1 → 成功 + $this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $target->id), [ + 'machine_ids' => [$m1->id], + ])->assertStatus(200); + + $this->assertDatabaseHas('machine_user', ['user_id' => $target->id, 'machine_id' => $m1->id]); + $this->assertDatabaseMissing('machine_user', ['user_id' => $target->id, 'machine_id' => $m2->id]); + } + + public function test_operator_cannot_assign_machines_to_non_managed_account(): void + { + $operator = User::factory()->create([ + 'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id, + ]); + // 旁線帳號(非 operator 的下層) + $sibling = User::factory()->create([ + 'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id, + ]); + $m1 = Machine::factory()->create(['company_id' => $this->company->id]); + $operator->machines()->attach($m1->id); + + $this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $sibling->id), [ + 'machine_ids' => [$m1->id], + ])->assertStatus(403); + } + + public function test_sync_preserves_authorizations_outside_operator_subset(): void + { + $operator = User::factory()->create([ + 'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id, + ]); + $target = User::factory()->create([ + 'company_id' => $this->company->id, 'is_admin' => false, 'level' => 2, 'parent_id' => $operator->id, + ]); + $m1 = Machine::factory()->create(['company_id' => $this->company->id]); + $m2 = Machine::factory()->create(['company_id' => $this->company->id]); + + $operator->machines()->attach($m1->id); // 操作者只可授權 m1 + $target->machines()->attach([$m1->id, $m2->id]); // 目標既有 m1、m2(m2 在操作者範圍外) + + // 操作者送出清空([]):應只移除自己範圍內的 m1,保留範圍外的 m2 + $this->actingAs($operator)->postJson(route('admin.machines.permissions.accounts.sync', $target->id), [ + 'machine_ids' => [], + ])->assertStatus(200); + + $this->assertDatabaseMissing('machine_user', ['user_id' => $target->id, 'machine_id' => $m1->id]); + $this->assertDatabaseHas('machine_user', ['user_id' => $target->id, 'machine_id' => $m2->id]); + } + + public function test_get_account_machines_only_lists_operator_subset(): void + { + $operator = User::factory()->create([ + 'company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id, + ]); + $target = User::factory()->create([ + 'company_id' => $this->company->id, 'is_admin' => false, 'level' => 2, 'parent_id' => $operator->id, + ]); + $m1 = Machine::factory()->create(['company_id' => $this->company->id]); + $m2 = Machine::factory()->create(['company_id' => $this->company->id]); + $operator->machines()->attach($m1->id); + + $response = $this->actingAs($operator)->getJson(route('admin.machines.permissions.accounts.get', $target->id)); + $response->assertStatus(200); + $ids = collect($response->json('machines'))->pluck('id')->all(); + + $this->assertContains($m1->id, $ids); + $this->assertNotContains($m2->id, $ids); // 操作者沒被授權的機台不應出現在可授權清單 + } +} diff --git a/tests/Feature/Admin/RoleHierarchyTest.php b/tests/Feature/Admin/RoleHierarchyTest.php new file mode 100644 index 0000000..c8f8a57 --- /dev/null +++ b/tests/Feature/Admin/RoleHierarchyTest.php @@ -0,0 +1,117 @@ + 'menu.data-config.sub-accounts', 'guard_name' => 'web']); + + $this->company = Company::create(['name' => 'Test Co', 'code' => 'TEST']); + + $role = Role::create(['name' => 'tenant-mgr', 'company_id' => $this->company->id, 'is_system' => false, 'guard_name' => 'web']); + $role->syncPermissions(['menu.data-config.sub-accounts']); + + $this->main = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => true, 'level' => 0, 'parent_id' => null]); + $this->main->assignRole($role); + + $this->subA = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id]); + $this->subA->assignRole($role); + + $this->subB = User::factory()->create(['company_id' => $this->company->id, 'is_admin' => false, 'level' => 1, 'parent_id' => $this->main->id]); + $this->subB->assignRole($role); + } + + private function makeRole(string $name, User $creator): Role + { + return Role::create([ + 'name' => $name, 'company_id' => $this->company->id, 'is_system' => false, + 'guard_name' => 'web', 'created_by' => $creator->id, + ]); + } + + public function test_role_visible_to_isolates_by_creator(): void + { + $roleMain = $this->makeRole('role-main', $this->main); + $roleA = $this->makeRole('role-a', $this->subA); + $roleB = $this->makeRole('role-b', $this->subB); + + // 子帳號 A 只看得到自己建立的角色 + $this->assertSame([$roleA->id], Role::visibleTo($this->subA)->pluck('id')->all()); + + // 主帳號看得到同公司全部角色(含 tenant-mgr 與三個新建的) + $mainVisible = Role::visibleTo($this->main)->pluck('id')->all(); + foreach ([$roleMain->id, $roleA->id, $roleB->id] as $id) { + $this->assertContains($id, $mainVisible); + } + } + + public function test_can_be_managed_by_creator_only_for_sub_account(): void + { + $roleMain = $this->makeRole('role-main', $this->main); + $roleA = $this->makeRole('role-a', $this->subA); + + $this->assertTrue($roleA->canBeManagedBy($this->subA)); // 自己建的 + $this->assertFalse($roleMain->canBeManagedBy($this->subA)); // 上層建的 + $this->assertTrue($roleMain->canBeManagedBy($this->main)); // 主帳號管全公司 + $this->assertTrue($roleA->canBeManagedBy($this->main)); // 主帳號管全公司 + } + + public function test_store_role_sets_created_by(): void + { + $this->actingAs($this->subA)->post(route('admin.data-config.sub-account-roles.store'), [ + 'name' => 'a-created-role', + 'permissions' => ['menu.data-config.sub-accounts'], + ])->assertSessionHas('success'); + + $role = Role::where('name', 'a-created-role')->where('company_id', $this->company->id)->first(); + $this->assertNotNull($role); + $this->assertSame($this->subA->id, $role->created_by); + } + + public function test_sub_account_cannot_update_others_role(): void + { + $roleB = $this->makeRole('role-b', $this->subB); + + // A 嘗試改 B 建的角色 → 被擋 + $this->actingAs($this->subA)->put(route('admin.data-config.sub-account-roles.update', $roleB->id), [ + 'name' => 'hijacked', + 'permissions' => [], + ])->assertSessionHas('error'); + + $this->assertDatabaseHas('roles', ['id' => $roleB->id, 'name' => 'role-b']); + } + + public function test_sub_account_cannot_delete_others_role(): void + { + $roleMain = $this->makeRole('role-main', $this->main); + + $this->actingAs($this->subA)->delete(route('admin.data-config.sub-account-roles.destroy', $roleMain->id)) + ->assertSessionHas('error'); + + $this->assertDatabaseHas('roles', ['id' => $roleMain->id]); + } +}