diff --git a/app/Http/Controllers/SystemSettings/Sidebar.php b/app/Http/Controllers/SystemSettings/Sidebar.php index 819180e6..4563d8d1 100644 --- a/app/Http/Controllers/SystemSettings/Sidebar.php +++ b/app/Http/Controllers/SystemSettings/Sidebar.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\SystemSettings; use App\Models\Auth\User; +use App\Models\Auth\UserAccessGroup; use App\Utilities\BusinessNavigation; use App\Utilities\SidebarPermissions; use App\Utilities\SystemAccess; @@ -35,13 +36,29 @@ class Sidebar extends Controller $settings = SidebarPermissions::settings(); $configured = in_array($subject, $settings['configured'] ?? [], true); + $selected = $configured ? (array) ($settings['subjects'][$subject] ?? []) : SidebarPermissions::allKeys(); + $source = $configured ? '当前使用单独配置。' : '未单独配置,使用系统默认完整页面。'; + $canInherit = str_starts_with($subject, 'user_'); + if (str_starts_with($subject, 'group_')) { + $group = UserAccessGroup::forCompany()->findOrFail(substr($subject, 6)); + $selected = $group->page_permissions ?? []; + $configured = true; + $source = '分组权限与用户的其他分组权限合并;用户例外配置优先。新分组默认没有页面权限。'; + } elseif ($canInherit && ! $configured) { + $target = User::findOrFail(substr($subject, 5)); + $selected = SidebarPermissions::allowedKeys($target) ?? SidebarPermissions::allKeys(); + $groups = UserAccessGroup::forUser($target); + $source = $groups->isNotEmpty() ? '正在继承分组:' . $groups->pluck('name')->implode('、') : '正在继承角色或系统默认权限。'; + } return view('system_settings.sidebar.edit', [ 'navigation' => BusinessNavigation::all(), 'subjects' => $subjects, 'subject' => $subject, 'configured' => $configured, - 'selected' => $configured ? (array) ($settings['subjects'][$subject] ?? []) : SidebarPermissions::allKeys(), + 'selected' => $selected, + 'source' => $source, + 'canInherit' => $canInherit, ]); } @@ -54,14 +71,30 @@ class Sidebar extends Controller 'subject' => ['required', Rule::in(array_keys($subjects))], 'permissions' => ['nullable', 'array'], 'permissions.*' => ['string', Rule::in(SidebarPermissions::allKeys())], + 'mode' => ['nullable', Rule::in(['custom', 'inherit'])], ]); + if ($request->input('mode') === 'inherit' && ! str_starts_with($subject, 'user_')) { + return back()->withErrors(['mode' => '只有用户例外配置可以恢复继承。']); + } + if (str_starts_with($subject, 'group_')) { + UserAccessGroup::forCompany()->findOrFail(substr($subject, 6))->update([ + 'page_permissions' => array_values(array_unique((array) $request->input('permissions', []))), + ]); + flash('分组页面权限已更新,成员将在下次访问时使用新权限。')->success(); + return redirect()->route('system-settings.sidebar.edit', ['subject' => $subject]); + } + $settings = SidebarPermissions::settings(); $settings['configured'] = array_values(array_unique(array_merge( (array) ($settings['configured'] ?? []), [$subject] ))); $settings['subjects'][$subject] = array_values(array_unique((array) $request->input('permissions', []))); + if ($request->input('mode') === 'inherit') { + $settings['configured'] = array_values(array_diff($settings['configured'], [$subject])); + unset($settings['subjects'][$subject]); + } setting()->set('system.sidebar_permissions', json_encode($settings, JSON_UNESCAPED_UNICODE)); setting()->save(); @@ -104,6 +137,12 @@ class Sidebar extends Controller $subjects['user_' . $managedUser->id] = '指定用户:' . $managedUser->name . ' - ' . ($labels[$role] ?? $role); } + if (SystemAccess::isSuperAdmin()) { + foreach (UserAccessGroup::forCompany()->orderBy('name')->get() as $group) { + $subjects['group_' . $group->id] = '分组:' . $group->name; + } + } + return $subjects; } } diff --git a/app/Http/Controllers/SystemSettings/UserGroups.php b/app/Http/Controllers/SystemSettings/UserGroups.php new file mode 100644 index 00000000..8fe25d91 --- /dev/null +++ b/app/Http/Controllers/SystemSettings/UserGroups.php @@ -0,0 +1,89 @@ +middleware(function ($request, $next) { + abort_unless(SystemAccess::isSuperAdmin(), 403); + return $next($request); + }); + } + + public function index() + { + return view('system_settings.groups.index', ['groups' => UserAccessGroup::forCompany()->withCount('users')->orderBy('name')->get()]); + } + + public function create() + { + return $this->form(); + } + + public function edit(int $group) + { + return $this->form(UserAccessGroup::forCompany()->findOrFail($group)); + } + + private function form(?UserAccessGroup $group = null) + { + return view('system_settings.groups.form', ['group' => $group, 'members' => $this->members(), 'selected' => $group?->users()->pluck('users.id')->all() ?? []]); + } + + public function store(Request $request) + { + $group = new UserAccessGroup; + $group->company_id = company_id(); + $group->page_permissions = []; + return $this->save($request, $group); + } + + public function update(Request $request, int $group) + { + return $this->save($request, UserAccessGroup::forCompany()->findOrFail($group)); + } + + private function save(Request $request, UserAccessGroup $group) + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:80', Rule::unique('user_access_groups', 'name')->where('company_id', company_id())->ignore($group->id)], + 'members' => ['nullable', 'array'], + 'members.*' => ['integer', 'distinct', Rule::in($this->members()->pluck('id')->all())], + ], [], ['name' => '分组名称', 'members' => '成员', 'members.*' => '成员']); + DB::transaction(function () use ($group, $data) { + $group->name = $data['name']; + $group->save(); + $group->users()->sync($data['members'] ?? []); + }); + flash('分组与成员已保存。页面权限在权限管理中设置。')->success(); + return redirect()->route('system-settings.groups.index'); + } + + public function destroy(int $group) + { + $group = UserAccessGroup::forCompany()->findOrFail($group); + if ($group->users()->exists()) { + return back()->withErrors(['group' => '请先移出分组成员,再删除空分组。移出最后一个分组的用户将恢复角色或系统默认权限。']); + } + $group->delete(); + flash('空分组已删除。')->success(); + return redirect()->route('system-settings.groups.index'); + } + + private function members() + { + return User::whereHas('companies', fn ($query) => $query->where('companies.id', company_id())) + ->whereHas('roles', fn ($query) => $query->whereIn('name', ['admin', 'user'])) + ->whereDoesntHave('roles', fn ($query) => $query->where('name', 'super_admin'))->orderBy('name')->get(); + } +} diff --git a/app/Http/Controllers/SystemSettings/Users.php b/app/Http/Controllers/SystemSettings/Users.php index d235de0c..4fe68757 100644 --- a/app/Http/Controllers/SystemSettings/Users.php +++ b/app/Http/Controllers/SystemSettings/Users.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\SystemSettings; use App\Models\Auth\Role; use App\Models\Auth\User; +use App\Models\Auth\UserAccessGroup; use App\Utilities\SystemAccess; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -24,10 +25,17 @@ class Users extends Controller public function index() { $users = $this->queryForActor()->with('roles')->orderBy('id')->get(); + $groupNamesByUser = []; + foreach (UserAccessGroup::forCompany()->with('users')->get() as $group) { + foreach ($group->users as $member) { + $groupNamesByUser[$member->id][] = $group->name; + } + } return view('system_settings.users.index', [ 'users' => $users, 'roleLabels' => SystemAccess::roleLabels(), + 'groupNamesByUser' => $groupNamesByUser, ]); } diff --git a/app/Http/Livewire/Menu/Settings.php b/app/Http/Livewire/Menu/Settings.php index 6debfa40..2532ccf5 100644 --- a/app/Http/Livewire/Menu/Settings.php +++ b/app/Http/Livewire/Menu/Settings.php @@ -39,6 +39,7 @@ class Settings extends Component 'icon' => $icon, 'search_keywords' => $keywords, 'active' => fn () => request()->routeIs(Str::beforeLast($route, '.') . '.*') + || ($route === 'system-settings.users.index' && request()->routeIs('system-settings.groups.*')) || ($route === 'system-settings.permissions.index' && request()->routeIs('system-settings.sidebar.*')), ]); diff --git a/app/Models/Auth/UserAccessGroup.php b/app/Models/Auth/UserAccessGroup.php new file mode 100644 index 00000000..66de10b8 --- /dev/null +++ b/app/Models/Auth/UserAccessGroup.php @@ -0,0 +1,27 @@ + 'array']; + + public function scopeForCompany($query) + { + return $query->where('company_id', company_id()); + } + + public function users() + { + return $this->belongsToMany(User::class, 'user_access_group_members', 'group_id', 'user_id'); + } + + public static function forUser(User $user) + { + return static::forCompany()->whereHas('users', fn ($query) => $query->where('users.id', $user->id))->orderBy('name')->get(); + } +} diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php index 04e4575d..40c45c90 100644 --- a/app/Utilities/SidebarPermissions.php +++ b/app/Utilities/SidebarPermissions.php @@ -3,6 +3,7 @@ namespace App\Utilities; use App\Models\Auth\User; +use App\Models\Auth\UserAccessGroup; class SidebarPermissions { @@ -36,7 +37,17 @@ class SidebarPermissions } $settings = self::settings(); - $subject = self::subjectKey($user); + $userSubject = 'user_' . $user->id; + if (in_array($userSubject, $settings['configured'] ?? [], true)) { + return array_values(array_unique((array) ($settings['subjects'][$userSubject] ?? []))); + } + + $groups = UserAccessGroup::forUser($user); + if ($groups->isNotEmpty()) { + return $groups->flatMap(fn ($group) => $group->page_permissions ?? [])->unique()->values()->all(); + } + + $subject = 'role_' . SystemAccess::roleName($user); if (! in_array($subject, $settings['configured'] ?? [], true)) { return null; diff --git a/database/migrations/2026_09_08_150000_create_user_access_groups.php b/database/migrations/2026_09_08_150000_create_user_access_groups.php new file mode 100644 index 00000000..ba6e96ac --- /dev/null +++ b/database/migrations/2026_09_08_150000_create_user_access_groups.php @@ -0,0 +1,33 @@ +increments('id'); + $table->unsignedInteger('company_id'); + $table->string('name', 80); + $table->json('page_permissions'); + $table->timestamps(); + $table->unique(['company_id', 'name']); + $table->foreign('company_id')->references('id')->on('companies')->cascadeOnDelete(); + }); + Schema::create('user_access_group_members', function (Blueprint $table) { + $table->unsignedInteger('group_id'); + $table->unsignedInteger('user_id'); + $table->primary(['group_id', 'user_id']); + $table->foreign('group_id')->references('id')->on('user_access_groups')->cascadeOnDelete(); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_access_group_members'); + Schema::dropIfExists('user_access_groups'); + } +}; diff --git a/docs/用户分组与页面权限.md b/docs/用户分组与页面权限.md new file mode 100644 index 00000000..b373f02a --- /dev/null +++ b/docs/用户分组与页面权限.md @@ -0,0 +1,33 @@ +# 用户分组与页面权限 + +用户管理的“页面权限”和权限管理的“用户可访问页面”共用同一编辑器和权限解析器,不是两套授权系统。 + +## 使用 + +主管理员在用户管理中创建分组、添加成员,再从分组列表进入“设置页面权限”。用户可加入多个分组,分组名称可修改,关联使用稳定 ID。分组只控制业务页面,不授予管理员身份或店铺密码读取等专属操作权限。 + +新组默认没有页面权限,可先建空组、配置页面,再添加成员。已有单独配置的用户会显示提示,可在其页面权限编辑器点击“恢复继承分组权限”。只有主管理员可以创建、修改成员、配置或删除分组;删除前必须先移出成员。 + +## 唯一的优先级 + +1. 主管理员拥有全部业务页面。 +2. 用户单独配置覆盖分组和角色;显式空配置表示无页面,不会自动回退。 +3. 没有用户单独配置且有分组时,各组允许页面取并集;空组不贡献权限,所有组都为空时无页面。 +4. 没有分组时沿用角色配置。 +5. 用户、分组、角色均未配置时,保留负责人要求的默认完整业务导航。 + +移出最后一个分组会恢复角色或系统默认权限,编辑成员页面有明确提示。现有用户不会被自动加入新组。分组和成员限定当前公司,其他公司 ID 不能用来配置权限。 + +所有业务入口仍通过 SidebarPermissions 解析同一份有效权限。菜单隐藏不是安全边界,已有页面、导出与数据接口的后端鉴权继续生效。主管理员身份变更仍使用原来的专用身份校验,分组不会修改角色。 + +## 存储与发布 + +新增系统配置库表 `user_access_groups`(公司、名称、页面权限)和 `user_access_group_members`(组与用户关系)。不复制上游数据,不修改既有身份、用户单独权限或默认配置。需要执行 `2026_09_08_150000_create_user_access_groups`;生产迁移必须独立取得授权并备份系统配置库。回滚代码时保留新表以免丢失分组配置。 + +## 参考与取舍 + +- [Microsoft Azure RBAC 最佳实践](https://learn.microsoft.com/en-us/azure/role-based-access-control/best-practices):优先将权限赋予组以降低逐人维护成本,使用稳定 ID,并控制管理员权限范围。 +- [AWS IAM 用户组](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_groups.html):一个用户可以属于多个组,组不嵌套。本系统采用多分组且不引入嵌套组。 +- [OWASP 授权指南](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html):每次请求验证权限,后端统一判断,并测试越权边界。 + +本系统的“用户单独配置覆盖”是为兼容现有配置作出的产品选择,不照搬云平台的整套策略引擎。默认完整页面是现有负责人要求;新建组采用空权限,便于逐步转向按岗位授权。 diff --git a/resources/views/system_settings/groups/form.blade.php b/resources/views/system_settings/groups/form.blade.php new file mode 100644 index 00000000..0348bcbf --- /dev/null +++ b/resources/views/system_settings/groups/form.blade.php @@ -0,0 +1,30 @@ + + {{ $group ? '管理分组成员' : '创建分组' }} + +
+ @include('system_settings.partials.navigation') + +
+ @csrf @if($group) @method('PUT') @endif + @if ($errors->any())@endif + +
+ 分组成员 +

新分组默认没有页面权限。保存后请设置页面权限;移出最后一个分组会恢复角色或系统默认权限。已有用户单独配置的成员仍按其单独配置访问。

+ @php $overrides = \App\Utilities\SidebarPermissions::settings()['configured'] ?? []; @endphp +
+ @forelse ($members as $member) + + @empty +

当前公司没有可加入的成员。主管理员始终拥有全部权限,无需加入分组。

+ @endforelse +
+
+
取消
+
+
+
+
diff --git a/resources/views/system_settings/groups/index.blade.php b/resources/views/system_settings/groups/index.blade.php new file mode 100644 index 00000000..c9e3f730 --- /dev/null +++ b/resources/views/system_settings/groups/index.blade.php @@ -0,0 +1,37 @@ + + 用户分组 + 创建分组 + +
+ @include('system_settings.partials.navigation') + +

用户可加入多个分组,获得各组页面权限的并集。分组不授予管理员身份;用户单独配置优先。

+ @if ($errors->any())@endif +
+ + + + @forelse ($groups as $group) + + + + + + + @empty + + @endforelse + +
分组名称成员页面权限操作
{{ $group->name }}{{ $group->users_count }}{{ empty($group->page_permissions) ? '未开放页面' : '已配置' }} + 管理成员 + 设置页面权限 + @if ($group->users_count === 0) +
+ @csrf @method('DELETE') +
+ @endif +
还没有用户分组。创建分组后,添加成员并统一设置页面权限。
+
+
+
+
diff --git a/resources/views/system_settings/partials/navigation-script.blade.php b/resources/views/system_settings/partials/navigation-script.blade.php index 351c9cbf..34bbfbe4 100644 --- a/resources/views/system_settings/partials/navigation-script.blade.php +++ b/resources/views/system_settings/partials/navigation-script.blade.php @@ -6,7 +6,7 @@ window.caiwuSettingsNavigation = true; let pending; const supports = url => url.origin === location.origin - && /\/system-settings\/(?:users(?:\/create|\/\d+\/edit)?|permissions|sidebar)$/.test(url.pathname); + && /\/system-settings\/(?:(?:users|groups)(?:\/create|\/\d+\/edit)?|permissions|sidebar)$/.test(url.pathname); async function navigate(url, push = true) { const current = document.querySelector('[data-settings-workspace]'); if (!current) return; diff --git a/resources/views/system_settings/partials/navigation.blade.php b/resources/views/system_settings/partials/navigation.blade.php index 65e1e1d4..c851f16d 100644 --- a/resources/views/system_settings/partials/navigation.blade.php +++ b/resources/views/system_settings/partials/navigation.blade.php @@ -11,7 +11,7 @@ @endphp @foreach ($items as [$routeName, $label, $icon]) - @php $active = request()->routeIs(Str::beforeLast($routeName, '.') . '.*') || ($routeName === 'system-settings.permissions.index' && request()->routeIs('system-settings.sidebar.*')); @endphp + @php $active = request()->routeIs(Str::beforeLast($routeName, '.') . '.*') || ($routeName === 'system-settings.permissions.index' && request()->routeIs('system-settings.sidebar.*')) || ($routeName === 'system-settings.users.index' && request()->routeIs('system-settings.groups.*')); @endphp 权限管理用户可访问页面
- 先选择权限身份或指定用户,再勾选可访问页面。指定用户配置优先于角色配置;主管理员拥有全部页面权限。 + 优先按分组设置页面。同一用户的多个分组权限合并,用户单独配置覆盖分组;主管理员始终拥有全部页面权限。
@@ -35,7 +35,8 @@

{{ $subjects[$subject] }}

-

{{ $configured ? '当前正在使用自定义权限。' : '当前未单独配置,默认显示全部目录。保存后将启用自定义权限。' }}

+

{{ $source }}

+ @if ($canInherit && $configured)

当前为用户自定义权限,分组变化不会覆盖此配置。

@endif
@@ -92,6 +93,9 @@
+ @if ($canInherit && $configured) + + @endif
diff --git a/resources/views/system_settings/sidebar/index.blade.php b/resources/views/system_settings/sidebar/index.blade.php index 4967cf9c..cc1ea3f8 100644 --- a/resources/views/system_settings/sidebar/index.blade.php +++ b/resources/views/system_settings/sidebar/index.blade.php @@ -9,6 +9,7 @@ 用户可访问页面设置用户或角色可以访问的业务页面。主管理员始终拥有全部页面权限。设置页面权限 @if (\App\Utilities\SystemAccess::isSuperAdmin()) + 分组页面权限按岗位创建分组、添加成员,再统一设置业务页面。多个分组权限合并。管理分组 用户权限身份在用户管理中设置普通用户、管理员或主管理员身份。管理用户身份 @endif diff --git a/resources/views/system_settings/users/index.blade.php b/resources/views/system_settings/users/index.blade.php index 9f660ca4..f7853898 100644 --- a/resources/views/system_settings/users/index.blade.php +++ b/resources/views/system_settings/users/index.blade.php @@ -2,6 +2,10 @@ 用户管理 + @if (\App\Utilities\SystemAccess::isSuperAdmin()) + 用户分组 + 创建分组 + @endif person_add 新增用户 @@ -44,6 +48,9 @@ {{ $managedUser->name }} + @if (! empty($groupNamesByUser[$managedUser->id])) + 分组:{{ implode('、', $groupNamesByUser[$managedUser->id]) }} + @endif @if ($managedUser->id === user()->id) 当前账号 @endif diff --git a/routes/admin.php b/routes/admin.php index b4979c4b..117f29d0 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -171,6 +171,7 @@ Route::delete('workspace/{section}/{page}/{id}/{tab?}', 'Business\Pages@destroy' ->name('business.pages.destroy'); Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], function () { + Route::resource('groups', 'SystemSettings\UserGroups')->except('show'); Route::get('users', 'SystemSettings\Users@index')->name('users.index'); Route::get('users/create', 'SystemSettings\Users@create')->name('users.create'); Route::post('users', 'SystemSettings\Users@store')->name('users.store'); diff --git a/tests/Feature/SystemSettings/UserAccessGroupsTest.php b/tests/Feature/SystemSettings/UserAccessGroupsTest.php new file mode 100644 index 00000000..74f37d6e --- /dev/null +++ b/tests/Feature/SystemSettings/UserAccessGroupsTest.php @@ -0,0 +1,145 @@ +member(); + $this->loginAs()->post(route('system-settings.groups.store'), ['name' => '核算组', 'members' => [$member->id]]) + ->assertRedirect(route('system-settings.groups.index')); + $group = UserAccessGroup::where('name', '核算组')->sole(); + $this->assertSame([], $group->page_permissions); + $this->assertSame([], SidebarPermissions::allowedKeys($member)); + $this->assertSame(['user'], $member->roles()->pluck('name')->all()); + $this->get(route('system-settings.users.index'))->assertOk()->assertSeeText('核算组'); + $this->get(route('system-settings.groups.index'))->assertOk()->assertSeeText('未开放页面'); + } + + public function test_multiple_group_permissions_are_unioned_and_enforced_on_real_routes(): void + { + $member = $this->member(); + $a = $this->group('核算', $member, ['store-management/basic-information']); + $b = $this->group('复核', $member, ['trademark-management/information']); + $this->assertEqualsCanonicalizing(['store-management/basic-information', 'trademark-management/information'], SidebarPermissions::allowedKeys($member)); + $this->withExceptionHandling()->loginAs($member)->get(route('business.pages.show', ['section' => 'company-management', 'page' => 'basic-information']))->assertForbidden(); + $this->loginAs()->patch(route('system-settings.sidebar.update'), ['subject' => 'group_'.$b->id, 'permissions' => []]) + ->assertRedirect(route('system-settings.sidebar.edit', ['subject' => 'group_'.$b->id])); + $this->assertSame(['store-management/basic-information'], SidebarPermissions::allowedKeys($member)); + $this->loginAs($member)->get(route('dashboard'))->assertRedirectContains('/workspace/store-management/basic-information'); + } + + public function test_user_override_can_be_empty_and_can_restore_group_inheritance(): void + { + $member = $this->member(); + $this->group('共享核算组', $member, ['store-management/basic-information']); + $this->loginAs()->patch(route('system-settings.sidebar.update'), ['subject' => 'user_'.$member->id, 'permissions' => []]); + $this->assertSame([], SidebarPermissions::allowedKeys($member)); + $this->get(route('system-settings.sidebar.edit', ['subject' => 'user_'.$member->id]))->assertOk()->assertSeeText('恢复继承分组权限'); + $this->patch(route('system-settings.sidebar.update'), ['subject' => 'user_'.$member->id, 'mode' => 'inherit']) + ->assertRedirect(route('system-settings.sidebar.edit', ['subject' => 'user_'.$member->id])); + $this->assertSame(['store-management/basic-information'], SidebarPermissions::allowedKeys($member)); + $this->get(route('system-settings.sidebar.edit', ['subject' => 'user_'.$member->id]))->assertOk()->assertSeeText('正在继承分组:共享核算组'); + } + + public function test_group_configuration_uses_the_existing_permissions_editor(): void + { + $member = $this->member(); + $group = $this->group('出纳组', $member); + $this->loginAs()->get(route('system-settings.sidebar.edit', ['subject' => 'group_'.$group->id]))->assertOk()->assertSeeText('分组:出纳组'); + $this->patch(route('system-settings.sidebar.update'), ['subject' => 'group_'.$group->id, 'permissions' => ['fund-management']]); + $this->assertSame(['fund-management'], $group->fresh()->page_permissions); + $this->assertSame(['fund-management'], SidebarPermissions::allowedKeys($member)); + $this->withExceptionHandling()->patchJson(route('system-settings.sidebar.update'), ['subject' => 'group_'.$group->id, 'permissions' => ['not-a-page']])->assertUnprocessable(); + } + + public function test_group_page_access_does_not_grant_store_password_access(): void + { + $member = $this->member(); + $this->group('店铺资料组', $member, ['store-management']); + $this->mock(\App\Services\StoreAccountPasswordClient::class)->shouldNotReceive('reveal'); + $this->withExceptionHandling()->loginAs($member)->postJson(route('business.store-account-passwords.reveal', ['account' => 'fixture-account']), ['purpose' => 'view'])->assertForbidden(); + } + + public function test_group_membership_and_names_can_change_without_changing_permissions(): void + { + $member = $this->member(); + $other = $this->member(); + $group = $this->group('旧名称', $member, ['store-management']); + $this->loginAs()->put(route('system-settings.groups.update', $group->id), ['name' => '新名称', 'members' => [$other->id]]) + ->assertRedirect(route('system-settings.groups.index')); + $this->assertSame(['store-management'], SidebarPermissions::allowedKeys($other)); + $this->assertNull(SidebarPermissions::allowedKeys($member)); + $this->assertSame(['store-management'], $group->fresh()->page_permissions); + $this->assertSame('新名称', $group->fresh()->name); + } + + public function test_delete_requires_an_empty_group(): void + { + $member = $this->member(); + $group = $this->group('核算组', $member); + $this->loginAs()->delete(route('system-settings.groups.destroy', $group->id))->assertSessionHasErrors('group'); + $this->assertNotNull($group->fresh()); + $this->put(route('system-settings.groups.update', $group->id), ['name' => '核算组']); + $this->delete(route('system-settings.groups.destroy', $group->id))->assertRedirect(route('system-settings.groups.index')); + $this->assertNull($group->fresh()); + } + + public function test_only_super_admins_can_change_groups_or_group_permissions(): void + { + $member = $this->member('admin'); + $group = $this->group('管理员业务组', $member); + $this->withExceptionHandling()->loginAs($member)->get(route('system-settings.groups.index'))->assertForbidden(); + $this->post(route('system-settings.groups.store'), ['name' => '不允许'])->assertForbidden(); + $this->put(route('system-settings.groups.update', $group->id), ['name' => '不允许'])->assertForbidden(); + $this->delete(route('system-settings.groups.destroy', $group->id))->assertForbidden(); + $this->patchJson(route('system-settings.sidebar.update'), ['subject' => 'group_'.$group->id, 'permissions' => ['store-management']])->assertUnprocessable(); + } + + public function test_group_and_member_ids_cannot_cross_company_or_grant_super_admin_membership(): void + { + $member = $this->member(); + $member->companies()->detach(); + $this->withExceptionHandling()->loginAs()->postJson(route('system-settings.groups.store'), ['name' => '跨公司', 'members' => [$member->id]])->assertUnprocessable(); + $this->postJson(route('system-settings.groups.store'), ['name' => '主管理员组', 'members' => [$this->user->id]])->assertUnprocessable(); + $group = $this->group('本公司', $this->member()); + $this->withHeaders(['X-Requested-With' => 'XMLHttpRequest'])->getJson(route('system-settings.groups.edit', $group->id + 9999))->assertNotFound(); + $otherCompany = $this->company->replicate(); + $otherCompany->domain = 'group-isolation.example.test'; + $otherCompany->save(); + $foreignGroup = new UserAccessGroup(['name' => '其他公司分组', 'page_permissions' => ['store-management']]); + $foreignGroup->company_id = $otherCompany->id; + $foreignGroup->save(); + $this->getJson(route('system-settings.groups.edit', $foreignGroup->id))->assertNotFound(); + $this->patchJson(route('system-settings.sidebar.update'), ['subject' => 'group_'.$foreignGroup->id, 'permissions' => []])->assertUnprocessable(); + $this->postJson(route('system-settings.groups.store'), ['name' => '本公司'])->assertUnprocessable(); + $this->assertNull(SidebarPermissions::allowedKeys($this->user)); + } + + private function member(string $role = 'user'): User + { + $name = 'member-'.User::count(); + $user = User::create(['name' => $name, 'email' => $name.'@example.test', 'password' => 'fixture-only-password', 'enabled' => true, 'locale' => 'zh-CN', 'landing_page' => 'dashboard']); + $user->roles()->attach(Role::where('name', $role)->sole()->id); + $user->permissions()->attach(Permission::where('name', 'read-admin-panel')->sole()->id); + $user->companies()->attach($this->company->id); + return $user; + } + + private function group(string $name, User $member, array $permissions = []): UserAccessGroup + { + $group = new UserAccessGroup(['name' => $name, 'page_permissions' => $permissions]); + $group->company_id = $this->company->id; + $group->save(); + $group->users()->attach($member->id); + return $group; + } +}