[FEAT] 帳號與角色多層級隔離(主/子帳號樹狀 + 角色權限/機台授權子集把關)
1. 主帳號不變量:storeAccount 的 is_admin 改補位(每家公司恰一主帳號)、destroyAccount 禁刪最後主帳號、roles 新增 is_company_admin 旗標保護公司管理員角色不被刪除 2. 帳號樹狀層級:users 新增 parent_id/level(主帳號 level 0、子帳號最多兩層),新增 visibleTo/canManageAccount,帳號清單與管理端點(accounts/update/destroy/toggle、機台授權選帳號、補貨指派)一律套層級可見範圍與越權防護 3. 角色指派子集:storeAccount/updateAccount 指派角色與帳號 Modal 下拉一律限制為「權限為操作者子集」,杜絕權限提升 4. 機台授權子集:MachinePermissionController 僅允許在操作者本身可授權機台範圍內增刪,保留範圍外既有授權,並修正 machine_access 全域 scope 對讀寫的干擾 5. 角色層級隔離:roles 新增 created_by,子帳號只看/只管/只能指派自己建立的角色(主帳號看全公司),edit/update/destroy 加 canBeManagedBy 守衛 6. 補強:MachineSetting 機台權限人員清單補租戶隔離、修正角色下拉 N+1、三語系新增提示字串 7. 測試:新增 AccountHierarchyTest/AccountSubsetGuardTest/RoleHierarchyTest,Admin 套件 55 測試 0 回歸 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7739d6016f
commit
201663741d
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
|
||||||
->count();
|
|
||||||
|
|
||||||
if ($validCount !== count($machineIds)) {
|
|
||||||
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);
|
||||||
|
|||||||
@ -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 角色
|
||||||
@ -527,15 +601,31 @@ class PermissionController extends Controller
|
|||||||
public function destroyAccount($id)
|
public function destroyAccount($id)
|
||||||
{
|
{
|
||||||
$user = \App\Models\System\User::findOrFail($id);
|
$user = \App\Models\System\User::findOrFail($id);
|
||||||
|
|
||||||
if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) {
|
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.'));
|
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();
|
||||||
|
|
||||||
|
|||||||
@ -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']);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 2,level 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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -275,6 +275,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",
|
||||||
@ -2065,6 +2066,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",
|
||||||
@ -2151,6 +2153,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.",
|
||||||
@ -2414,9 +2417,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",
|
||||||
|
|||||||
@ -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 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-actions": "サブアクション",
|
"Sub-actions": "サブアクション",
|
||||||
"Sub-machine": "サブマシン",
|
"Sub-machine": "サブマシン",
|
||||||
"Sub-machine Status": "Sub-machine Status",
|
"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 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未満の画像をアップロードしてください。",
|
||||||
@ -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.": "最大 :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",
|
||||||
|
|||||||
@ -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 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-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 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 的圖片。",
|
||||||
@ -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.": "最多只能選擇 :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",
|
||||||
|
|||||||
157
tests/Feature/Admin/AccountHierarchyTest.php
Normal file
157
tests/Feature/Admin/AccountHierarchyTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
160
tests/Feature/Admin/AccountSubsetGuardTest.php
Normal file
160
tests/Feature/Admin/AccountSubsetGuardTest.php
Normal 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、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); // 操作者沒被授權的機台不應出現在可授權清單
|
||||||
|
}
|
||||||
|
}
|
||||||
117
tests/Feature/Admin/RoleHierarchyTest.php
Normal file
117
tests/Feature/Admin/RoleHierarchyTest.php
Normal 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_by;update/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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user