[RELEASE] demo -> main: 帳號與角色多層級隔離上正式站

1. 帳號與角色多層級隔離:主/子帳號樹狀 (parent_id/level)、主帳號唯一性 (is_admin 補位/禁刪最後主帳號)、角色權限與機台授權子集把關、角色層級隔離 (created_by)
2. 含 3 支具 backfill 的 migration(is_company_admin / parent_id+level / created_by):部署後須跑 migrate;會將既有非主帳號設為主帳號之下層、軟刪除帳號清 is_admin、公司角色歸屬主帳號
3. 機台日誌層級篩選 + 銷售紀錄付款/出貨狀態篩選

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sky121113 2026-06-25 17:30:24 +08:00
commit be9c70d524
15 changed files with 981 additions and 48 deletions

View File

@ -75,8 +75,10 @@ class MachineSettingController extends AdminController
break; break;
case 'permissions': case 'permissions':
// 套用層級可見範圍:系統管理員看全部;租戶僅看可見範圍內帳號(避免跨租戶洩漏)。
$userQuery = \App\Models\System\User::query() $userQuery = \App\Models\System\User::query()
->where('is_admin', true) ->where('is_admin', true)
->visibleTo($currentUser)
->with(['company', 'machines']); ->with(['company', 'machines']);
if ($search) { if ($search) {
$userQuery->where(function($q) use ($search) { $userQuery->where(function($q) use ($search) {
@ -121,6 +123,7 @@ class MachineSettingController extends AdminController
$userQuery = \App\Models\System\User::query() $userQuery = \App\Models\System\User::query()
->where('is_admin', true) ->where('is_admin', true)
->visibleTo($currentUser)
->with(['company', 'machines']); ->with(['company', 'machines']);
$users_list = $userQuery->latest()->paginate($per_page)->withQueryString(); $users_list = $userQuery->latest()->paginate($per_page)->withQueryString();

View File

@ -32,10 +32,9 @@ class MachinePermissionController extends AdminController
}]) }])
->whereNotNull('company_id'); ->whereNotNull('company_id');
// 非系統管理員僅能看到同公司的帳號 (因 User Model 排除 TenantScoped 全域過濾,需手動注入) // 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。
if (!$currentUser->isSystemAdmin()) { $userQuery->visibleTo($currentUser);
$userQuery->where('company_id', $currentUser->company_id); if ($currentUser->isSystemAdmin() && $company_id) {
} elseif ($company_id) {
// 系統管理員的篩選邏輯 // 系統管理員的篩選邏輯
$userQuery->where('company_id', $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')); 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: 取得特定帳號的機台分配狀態 * AJAX: 取得特定帳號的機台分配狀態
*/ */
@ -61,17 +85,18 @@ class MachinePermissionController extends AdminController
{ {
$currentUser = auth()->user(); $currentUser = auth()->user();
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員) // 層級越權防護:只能操作可管轄範圍內的帳號。
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) { if (!$currentUser->canManageAccount($user)) {
return response()->json(['error' => 'Unauthorized'], 403); return response()->json(['error' => 'Unauthorized'], 403);
} }
// 取得該使用者所屬公司之所有機台 (忽略個別帳號的 machine_access 限制,以公司為單位顯示) // 可授權機台清單限縮為「操作者本身可授權」的機台子集,避免把自己沒有的機台授權出去。
$assignableIds = $this->assignableMachineIds($currentUser, $user->company_id);
$machines = Machine::withoutGlobalScope('machine_access') $machines = Machine::withoutGlobalScope('machine_access')
->where('company_id', $user->company_id) ->whereIn('id', $assignableIds)
->get(['id', 'name', 'serial_no']); ->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([ return response()->json([
'user' => $user, 'user' => $user,
@ -87,8 +112,8 @@ class MachinePermissionController extends AdminController
{ {
$currentUser = auth()->user(); $currentUser = auth()->user();
// 安全檢查 // 層級越權防護:只能操作可管轄範圍內的帳號。
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) { if (!$currentUser->canManageAccount($user)) {
return response()->json(['error' => 'Unauthorized'], 403); return response()->json(['error' => 'Unauthorized'], 403);
} }
@ -97,20 +122,27 @@ class MachinePermissionController extends AdminController
'machine_ids.*' => 'exists:machines,id' 'machine_ids.*' => 'exists:machines,id'
]); ]);
// 加固驗證:確保所有機台 ID 都屬於該使用者的公司 (使用 withoutGlobalScope 避免管理員自身權限影響驗證邏輯) // 授權子集約束:被指派的機台必須是「操作者本身可授權機台」的子集,
if ($request->has('machine_ids')) { // 既確保同公司,也避免子帳號把自己沒被授權的機台授權給他人。
$machineIds = array_unique($request->machine_ids); $assignableIds = $this->assignableMachineIds($currentUser, $user->company_id);
$validCount = Machine::withoutGlobalScope('machine_access') $submittedIds = collect($request->machine_ids ?? [])->map(fn ($id) => (int) $id)->unique();
->where('company_id', $user->company_id) if ($submittedIds->diff($assignableIds)->isNotEmpty()) {
->whereIn('id', $machineIds)
->count();
if ($validCount !== count($machineIds)) {
return response()->json(['error' => 'Invalid machine IDs provided.'], 422); 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.'); $message = __('Machine permissions updated successfully.');
session()->flash('success', $message); session()->flash('success', $message);

View File

@ -14,10 +14,8 @@ class PermissionController extends Controller
$user = auth()->user(); $user = auth()->user();
$query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']); $query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
// 租戶隔離:租戶只能看到自己公司的角色 // 層級隔離:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己建立的角色。
if (!$user->isSystemAdmin()) { $query->visibleTo($user);
$query->where('company_id', $user->company_id);
}
// 搜尋:角色名稱(支援 roles_search 命名空間 與舊版 search // 搜尋:角色名稱(支援 roles_search 命名空間 與舊版 search
$search = request()->input('roles_search', request()->input('search')); $search = request()->input('roles_search', request()->input('search'));
@ -88,6 +86,11 @@ class PermissionController extends Controller
$role = \App\Models\System\Role::findOrFail($id); $role = \App\Models\System\Role::findOrFail($id);
$user = auth()->user(); $user = auth()->user();
// 層級隔離:子帳號只能編輯自己建立的角色,不可開啟上層/旁線建立的角色。
if (!$role->canBeManagedBy($user)) {
return redirect()->back()->with('error', __('You do not have permission to manage this role.'));
}
// 權限遞迴約束與分組邏輯由 getFilteredPermissions 處理 // 權限遞迴約束與分組邏輯由 getFilteredPermissions 處理
$all_permissions = $this->getFilteredPermissions($user); $all_permissions = $this->getFilteredPermissions($user);
@ -126,6 +129,7 @@ class PermissionController extends Controller
'name' => $validated['name'], 'name' => $validated['name'],
'guard_name' => 'web', 'guard_name' => 'web',
'company_id' => $is_system ? null : auth()->user()->company_id, 'company_id' => $is_system ? null : auth()->user()->company_id,
'created_by' => auth()->id(),
'is_system' => $is_system, '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.')); 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; $is_system = auth()->user()->isSystemAdmin() ? $request->boolean('is_system') : $role->is_system;
$updateData = [ $updateData = [
@ -223,10 +232,20 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('The Super Admin role cannot be deleted.')); 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) { if (!auth()->user()->isSystemAdmin() && $role->is_system) {
return redirect()->back()->with('error', __('System roles cannot be deleted by tenant administrators.')); 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) { if ($role->users()->count() > 0) {
return redirect()->back()->with('error', __('Cannot delete role with active users.')); 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(); $currentUserRoleIds = $user->roles->pluck('id')->toArray();
// ── 帳號列表 ────────────────────────────────────────────────── // ── 帳號列表 ──────────────────────────────────────────────────
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines']); // 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層。
if (!$user->isSystemAdmin()) { $usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines'])->visibleTo($user);
$usersQuery->where('company_id', $user->company_id);
}
if ($search = $request->input('accounts_search', $request->input('search'))) { if ($search = $request->input('accounts_search', $request->input('search'))) {
$usersQuery->where(function ($q) use ($search) { $usersQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%") $q->where('name', 'like', "%{$search}%")
@ -269,22 +286,24 @@ class PermissionController extends Controller
$accounts_per_page = $request->input('accounts_per_page', 10); $accounts_per_page = $request->input('accounts_per_page', 10);
$users = $usersQuery->latest()->paginate($accounts_per_page, ['*'], 'accounts_page')->withQueryString(); $users = $usersQuery->latest()->paginate($accounts_per_page, ['*'], 'accounts_page')->withQueryString();
// Modal 用的角色選單(不分頁) // Modal 用的角色選單(不分頁)。層級隔離:子帳號僅見自己建立的角色(與角色清單一致)。
$rolesForSelect = \App\Models\System\Role::query(); // 預先載入 permissions 以避免下方子集過濾造成 N+1。
if (!$user->isSystemAdmin()) { $rolesForSelect = \App\Models\System\Role::query()->with('permissions')->visibleTo($user);
$rolesForSelect->forCompany($user->company_id);
}
$roles = $rolesForSelect->get(); $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 版需要) ───────────────────── // ── 角色列表 (僅 data-config Tab 版需要) ─────────────────────
$paginated_roles = collect(); $paginated_roles = collect();
$all_permissions = collect(); $all_permissions = collect();
if ($isSubAccountRoute) { if ($isSubAccountRoute) {
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']); $rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company'])->visibleTo($user);
if (!$user->isSystemAdmin()) {
$rolesQuery->where('company_id', $user->company_id);
}
if ($search = $request->input('roles_search')) { if ($search = $request->input('roles_search')) {
$rolesQuery->where('name', 'like', "%{$search}%"); $rolesQuery->where('name', 'like', "%{$search}%");
} }
@ -328,6 +347,12 @@ class PermissionController extends Controller
'phone' => 'nullable|string|max:20', '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; $company_id = auth()->user()->isSystemAdmin() ? ($validated['company_id'] ?? null) : auth()->user()->company_id;
// 查找角色:優先尋找該公司的角色,若無則尋找全域範本 // 查找角色:優先尋找該公司的角色,若無則尋找全域範本
@ -374,6 +399,7 @@ class PermissionController extends Controller
'guard_name' => 'web', 'guard_name' => 'web',
'company_id' => $company_id, 'company_id' => $company_id,
'is_system' => false, 'is_system' => false,
'is_company_admin' => true,
]); ]);
$newRole->syncPermissions($role->getPermissionNames()); $newRole->syncPermissions($role->getPermissionNames());
$role = $newRole; $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([ $user = \App\Models\System\User::create([
'name' => $validated['name'], 'name' => $validated['name'],
'username' => $validated['username'], 'username' => $validated['username'],
@ -390,8 +449,10 @@ class PermissionController extends Controller
'password' => \Illuminate\Support\Facades\Hash::make($validated['password']), 'password' => \Illuminate\Support\Facades\Hash::make($validated['password']),
'status' => $validated['status'], 'status' => $validated['status'],
'company_id' => $company_id, 'company_id' => $company_id,
'parent_id' => $parentId,
'level' => $level,
'phone' => $validated['phone'] ?? null, 'phone' => $validated['phone'] ?? null,
'is_admin' => (auth()->user()->isSystemAdmin() && !empty($validated['company_id'])), 'is_admin' => $isMainAccount,
]); ]);
$user->assignRole($role); $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.')); 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([ $validated = $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'username' => 'required|string|max:255|unique:users,username,' . $id, 'username' => 'required|string|max:255|unique:users,username,' . $id,
@ -500,7 +566,7 @@ class PermissionController extends Controller
'guard_name' => 'web', 'guard_name' => 'web',
'company_id' => $target_company_id, 'company_id' => $target_company_id,
'is_system' => false, 'is_system' => false,
'is_admin' => true, 'is_company_admin' => true,
]); ]);
$newRole->syncPermissions($roleObj->getPermissionNames()); $newRole->syncPermissions($roleObj->getPermissionNames());
$roleObj = $newRole; $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); $user->update($updateData);
// 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色 // 如果是編輯自己且原本是超級管理員,強制保留 super-admin 角色
@ -532,10 +606,26 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('System super admin accounts can only be deleted by other super admins.')); 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()) { if ($user->id === auth()->id()) {
return redirect()->back()->with('error', __('You cannot delete your own account.')); 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(); $timestamp = now()->getTimestamp();
$user->username = $user->username . '.deleted.' . $timestamp; $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.')); 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->status = $user->status ? 0 : 1;
$user->save(); $user->save();

View File

@ -840,10 +840,9 @@ class WarehouseController extends Controller
$machines = $applyCompanyFilter(Machine::query())->orderBy('name')->get(['id', 'name', 'serial_no']); $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']); $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 = \App\Models\System\User::where('status', 1)->orderBy('name')->visibleTo($currentUser);
$userQuery->where('company_id', $currentUser->company_id); if ($isSystemAdmin && $companyId !== '') {
} elseif ($companyId !== '') {
$userQuery->where('company_id', $companyId); $userQuery->where('company_id', $companyId);
} }
$users = $userQuery->get(['id', 'name']); $users = $userQuery->get(['id', 'name']);

View File

@ -10,11 +10,14 @@ class Role extends SpatieRole
'name', 'name',
'guard_name', 'guard_name',
'company_id', 'company_id',
'created_by',
'is_system', 'is_system',
'is_company_admin',
]; ];
protected $casts = [ protected $casts = [
'is_system' => 'boolean', 'is_system' => 'boolean',
'is_company_admin' => 'boolean',
]; ];
/** /**
@ -25,6 +28,14 @@ class Role extends SpatieRole
return $this->belongsTo(Company::class); 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. * 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'); ->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;
}
} }

View File

@ -23,6 +23,8 @@ class User extends Authenticatable
*/ */
protected $fillable = [ protected $fillable = [
'company_id', 'company_id',
'parent_id',
'level',
'username', 'username',
'name', 'name',
'email', 'email',
@ -53,8 +55,14 @@ class User extends Authenticatable
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'is_admin' => 'boolean', 'is_admin' => 'boolean',
'level' => 'integer',
]; ];
/**
* 帳號層級上限:子帳號最多兩層(主帳號 level 0 level 1 level 2level 2 不可再建)。
*/
public const MAX_LEVEL = 2;
/** /**
* Get the login logs for the user. * Get the login logs for the user.
*/ */
@ -95,6 +103,85 @@ class User extends Authenticatable
return !is_null($this->company_id); 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. * Get the URL for the user's avatar.
*/ */

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* 為公司層級的「管理員」角色加入明確的權威旗標 is_company_admin
* 取代過去靠中文 name='管理員' 來辨識主帳號角色的脆弱作法。
*/
public function up(): void
{
if (!Schema::hasColumn('roles', 'is_company_admin')) {
Schema::table('roles', function (Blueprint $table) {
$table->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');
});
}
}
};

View File

@ -0,0 +1,113 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* 帳號樹狀層級化:加入 parent_id建立者 level主帳號=0)。
* 並對既有資料做兩步 backfill
* 1. is_admin 正規化:每家「有帳號但無主帳號」的公司,將最早建立的帳號設為主帳號;多主帳號則只保留最早一個。
* 2. 回填 parent_id / level主帳號 level=0、parent_id=null其餘帳號掛在該公司主帳號下、level=1
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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);
}
});
}
};

View File

@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* 角色樹狀層級化:加入 created_by建立者帳號
* Backfill既有公司層級角色 created_by 指向該公司主帳號;全域系統角色維持 null
*/
public function up(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->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'));
}
}
};

View File

@ -280,6 +280,7 @@
"Cannot delete company with active accounts.": "Cannot delete company with active accounts.", "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 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 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 delete warehouse with existing stock": "Cannot delete warehouse with existing stock",
"Cannot match original stored-value transaction": "Cannot match original stored-value transaction", "Cannot match original stored-value transaction": "Cannot match original stored-value transaction",
"Card Machine System": "Card Machine System", "Card Machine System": "Card Machine System",
@ -2088,6 +2089,7 @@
"Sub Account Management": "Sub Account Management", "Sub Account Management": "Sub Account Management",
"Sub Account Roles": "Sub Account Roles", "Sub Account Roles": "Sub Account Roles",
"Sub Accounts": "Sub Accounts", "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-actions": "Sub-actions",
"Sub-machine": "Sub-machine", "Sub-machine": "Sub-machine",
"Sub-machine Status": "Sub-machine Status", "Sub-machine Status": "Sub-machine Status",
@ -2174,6 +2176,7 @@
"The Super Admin role cannot be deleted.": "The Super Admin role cannot be deleted.", "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 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 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 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 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.", "The image is too large. Please upload an image smaller than 1MB.": "The image is too large. Please upload an image smaller than 1MB.",
@ -2439,9 +2442,12 @@
"Yesterday": "Yesterday", "Yesterday": "Yesterday",
"You can assign or change the personnel handling this order": "You can assign or change the personnel handling this order", "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 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 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 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 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 account is disabled.": "Your account is disabled.",
"Your recent account activity": "Your recent account activity", "Your recent account activity": "Your recent account activity",
"[PickupCode] Verification failed: :code": "[PickupCode] Verification failed: :code", "[PickupCode] Verification failed: :code": "[PickupCode] Verification failed: :code",

View File

@ -280,6 +280,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 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": "カードリーダーシステム",
@ -2088,6 +2089,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-actions": "サブアクション", "Sub-actions": "サブアクション",
"Sub-machine": "サブマシン", "Sub-machine": "サブマシン",
"Sub-machine Status": "Sub-machine Status", "Sub-machine Status": "Sub-machine Status",
@ -2174,6 +2176,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 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.": "画像サイズが大きすぎます。1MB未満の画像をアップロードしてください。", "The image is too large. Please upload an image smaller than 1MB.": "画像サイズが大きすぎます。1MB未満の画像をアップロードしてください。",
@ -2439,9 +2442,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.": "最大 :max 言語まで選択できます。", "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 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 role.": "このロールを管理する権限がありません。",
"Your account is disabled.": "アカウントが無効化されています。", "Your account is disabled.": "アカウントが無効化されています。",
"Your recent account activity": "最近のアカウントアクティビティ", "Your recent account activity": "最近のアカウントアクティビティ",
"[PickupCode] Verification failed: :code": "[受取コード] 検証失敗: :code", "[PickupCode] Verification failed: :code": "[受取コード] 検証失敗: :code",

View File

@ -280,6 +280,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 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": "刷卡機系統",
@ -2088,6 +2089,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-actions": "子項目", "Sub-actions": "子項目",
"Sub-machine": "下位機", "Sub-machine": "下位機",
"Sub-machine Status": "下位機狀態", "Sub-machine Status": "下位機狀態",
@ -2174,6 +2176,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 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.": "圖片檔案太大,請上傳小於 1MB 的圖片。", "The image is too large. Please upload an image smaller than 1MB.": "圖片檔案太大,請上傳小於 1MB 的圖片。",
@ -2439,9 +2442,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.": "最多只能選擇 :max 種語系。", "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 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 role.": "您沒有權限管理此角色。",
"Your account is disabled.": "您的帳號已被停用。", "Your account is disabled.": "您的帳號已被停用。",
"Your recent account activity": "最近的帳號活動", "Your recent account activity": "最近的帳號活動",
"[PickupCode] Verification failed: :code": "[取貨碼] 驗證失敗: :code", "[PickupCode] Verification failed: :code": "[取貨碼] 驗證失敗: :code",

View File

@ -0,0 +1,157 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 帳號樹狀層級隔離(第二+三批)行為測試:
* - 主帳號唯一性(第一個帳號自動成為主帳號)
* - parent_id / level 歸屬
* - 子帳號深度上限level 2 不可再建)
* - visibleTo 可見範圍隔離(只看自己直建的)
* - canManageAccount 越權防護(不能管旁線)
*/
class AccountHierarchyTest extends TestCase
{
use RefreshDatabase;
protected User $sysAdmin;
protected Company $company;
protected Role $tenantRole;
protected function setUp(): void
{
parent::setUp();
foreach (['menu.permissions.accounts', 'menu.data-config.sub-accounts'] as $p) {
Permission::create(['name' => $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');
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\Machine\Machine;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 第四批:角色指派子集 機台授權子集 把關測試。
* - 操作者不可指派「含自身未持有權限」的角色(防權限提升)。
* - 操作者不可授權「自己未被授權」的機台給他人(防機台層級提升)。
* - 操作者不可對非管轄帳號操作機台授權(越權)。
*/
class AccountSubsetGuardTest extends TestCase
{
use RefreshDatabase;
protected Company $company;
protected User $main;
protected function setUp(): void
{
parent::setUp();
foreach (['menu.permissions.accounts', 'menu.data-config.sub-accounts', 'extra.high.perm'] as $p) {
Permission::create(['name' => $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、m2m2 在操作者範圍外)
// 操作者送出清空([]):應只移除自己範圍內的 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); // 操作者沒被授權的機台不應出現在可授權清單
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\System\Company;
use App\Models\System\Role;
use App\Models\System\User;
use Spatie\Permission\Models\Permission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 第五批:角色層級隔離測試。
* - 子帳號只看得到 / 只能管理 自己建立的角色(看不到上層/旁線建的)。
* - 主帳號看得到同公司全部角色。
* - storeRole 寫入 created_byupdate/destroy canBeManagedBy 守衛。
*/
class RoleHierarchyTest extends TestCase
{
use RefreshDatabase;
protected Company $company;
protected User $main;
protected User $subA;
protected User $subB;
protected function setUp(): void
{
parent::setUp();
Permission::create(['name' => '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]);
}
}