用户多分组及分组页面权限 #122

Merged
WuYongJie merged 2 commits from feature/WuYongJie-user-access-groups into main 2026-09-08 14:30:49 +08:00
17 changed files with 472 additions and 6 deletions

View File

@ -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;
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\SystemSettings;
use App\Models\Auth\User;
use App\Models\Auth\UserAccessGroup;
use App\Utilities\SystemAccess;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
class UserGroups extends Controller
{
public function __construct()
{
$this->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();
}
}

View File

@ -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,
]);
}

View File

@ -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.*')),
]);

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models\Auth;
use Illuminate\Database\Eloquent\Model;
class UserAccessGroup extends Model
{
protected $table = 'user_access_groups';
protected $fillable = ['name', 'page_permissions'];
protected $casts = ['page_permissions' => '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();
}
}

View File

@ -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;

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('user_access_groups', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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):每次请求验证权限,后端统一判断,并测试越权边界。
本系统的“用户单独配置覆盖”是为兼容现有配置作出的产品选择,不照搬云平台的整套策略引擎。默认完整页面是现有负责人要求;新建组采用空权限,便于逐步转向按岗位授权。

View File

@ -0,0 +1,30 @@
<x-layouts.admin>
<x-slot name="title">{{ $group ? '管理分组成员' : '创建分组' }}</x-slot>
<x-slot name="content">
<div class="my-6" data-settings-workspace>
@include('system_settings.partials.navigation')
<nav class="mb-4 text-sm" aria-label="页面位置"><a class="settings-accent" href="{{ route('system-settings.groups.index') }}">用户分组</a><span class="mx-2">/</span><span aria-current="page">{{ $group ? $group->name : '创建分组' }}</span></nav>
<form method="POST" action="{{ $group ? route('system-settings.groups.update', $group->id) : route('system-settings.groups.store') }}" class="rounded-xl border border-gray-200 bg-white p-6">
@csrf @if($group) @method('PUT') @endif
@if ($errors->any())<div role="alert" class="mb-4 text-sm text-red-700">@foreach($errors->all() as $error)<p>{{ $error }}</p>@endforeach</div>@endif
<label class="block max-w-xl"><span class="text-sm font-semibold">分组名称 *</span><input name="name" required maxlength="80" value="{{ old('name', $group?->name) }}" class="mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 settings-field-focus"></label>
<fieldset class="mt-6">
<legend class="text-sm font-semibold">分组成员</legend>
<p class="mt-2 mb-4 text-sm text-gray-600">新分组默认没有页面权限。保存后请设置页面权限;移出最后一个分组会恢复角色或系统默认权限。已有用户单独配置的成员仍按其单独配置访问。</p>
@php $overrides = \App\Utilities\SidebarPermissions::settings()['configured'] ?? []; @endphp
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
@forelse ($members as $member)
<label class="flex items-start gap-2 text-sm">
<input type="checkbox" name="members[]" value="{{ $member->id }}" @checked(in_array($member->id, old('members', $errors->any() ? [] : $selected))) class="mt-1 rounded settings-accent settings-field-focus">
<span>{{ $member->name }}<span class="block text-xs text-gray-600 break-all">{{ $member->email }}</span>@if(in_array('user_'.$member->id, $overrides, true))<span class="block text-xs settings-accent">用户单独配置优先</span>@endif</span>
</label>
@empty
<p class="text-sm text-gray-600">当前公司没有可加入的成员。主管理员始终拥有全部权限,无需加入分组。</p>
@endforelse
</div>
</fieldset>
<div class="mt-6 flex justify-end gap-4 border-t border-gray-200 pt-4"><a class="settings-accent" href="{{ route('system-settings.groups.index') }}">取消</a><button class="settings-primary" type="submit">保存分组</button></div>
</form>
</div>
</x-slot>
</x-layouts.admin>

View File

@ -0,0 +1,37 @@
<x-layouts.admin>
<x-slot name="title">用户分组</x-slot>
<x-slot name="buttons"><a href="{{ route('system-settings.groups.create') }}" class="settings-primary">创建分组</a></x-slot>
<x-slot name="content">
<div class="my-6" data-settings-workspace>
@include('system_settings.partials.navigation')
<nav class="mb-4 text-sm" aria-label="页面位置"><a class="settings-accent" href="{{ route('system-settings.users.index') }}">用户管理</a><span class="mx-2">/</span><span aria-current="page">用户分组</span></nav>
<p class="mb-4 text-sm text-gray-600">用户可加入多个分组,获得各组页面权限的并集。分组不授予管理员身份;用户单独配置优先。</p>
@if ($errors->any())<div role="alert" class="mb-4 text-sm text-red-700">{{ $errors->first() }}</div>@endif
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white">
<table class="min-w-full text-sm text-left">
<thead class="bg-gray-50"><tr><th class="px-5 py-3 whitespace-nowrap">分组名称</th><th class="px-5 py-3 whitespace-nowrap">成员</th><th class="px-5 py-3 whitespace-nowrap">页面权限</th><th class="px-5 py-3 text-right whitespace-nowrap">操作</th></tr></thead>
<tbody>
@forelse ($groups as $group)
<tr class="border-t border-gray-200">
<td class="px-5 py-4 break-words">{{ $group->name }}</td>
<td class="px-5 py-4">{{ $group->users_count }}</td>
<td class="px-5 py-4">{{ empty($group->page_permissions) ? '未开放页面' : '已配置' }}</td>
<td class="px-5 py-4 text-right whitespace-nowrap">
<a class="settings-accent mr-3" href="{{ route('system-settings.groups.edit', $group->id) }}">管理成员</a>
<a class="settings-accent mr-3" href="{{ route('system-settings.sidebar.edit', ['subject' => 'group_'.$group->id]) }}">设置页面权限</a>
@if ($group->users_count === 0)
<form class="inline" method="POST" action="{{ route('system-settings.groups.destroy', $group->id) }}" onsubmit="return confirm('确定删除此空分组吗?')">
@csrf @method('DELETE')<button type="submit" class="text-red-700">删除</button>
</form>
@endif
</td>
</tr>
@empty
<tr><td colspan="4" class="px-5 py-10 text-center text-gray-600">还没有用户分组。创建分组后,添加成员并统一设置页面权限。</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</x-slot>
</x-layouts.admin>

View File

@ -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;

View File

@ -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
<a
href="{{ route($routeName) }}"
@class([

View File

@ -7,7 +7,7 @@
<nav class="mb-4 text-sm" aria-label="页面位置"><a class="settings-accent" href="{{ route('system-settings.permissions.index') }}">权限管理</a><span class="mx-2" aria-hidden="true">/</span><span aria-current="page">用户可访问页面</span></nav>
<div class="mb-5 rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
先选择权限身份或指定用户,再勾选可访问页面。指定用户配置优先于角色配置;主管理员拥有全部页面权限。
优先按分组设置页面。同一用户的多个分组权限合并,用户单独配置覆盖分组;主管理员始终拥有全部页面权限。
</div>
<form method="GET" action="{{ route('system-settings.sidebar.edit') }}" class="mb-5 rounded-xl border border-gray-200 bg-white p-5">
@ -35,7 +35,8 @@
<div class="mb-5 flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 pb-4">
<div>
<h2 class="text-sm font-semibold text-gray-800">{{ $subjects[$subject] }}</h2>
<p class="mt-1 text-sm text-gray-500">{{ $configured ? '当前正在使用自定义权限。' : '当前未单独配置,默认显示全部目录。保存后将启用自定义权限。' }}</p>
<p class="mt-1 text-sm text-gray-600">{{ $source }}</p>
@if ($canInherit && $configured)<p class="mt-1 text-sm text-gray-600">当前为用户自定义权限,分组变化不会覆盖此配置。</p>@endif
</div>
<div class="flex gap-2">
<button type="button" onclick="document.querySelectorAll('[data-sidebar-permission]').forEach(el => el.checked = true)" class="rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">全选</button>
@ -92,6 +93,9 @@
</div>
<div class="mt-6 flex justify-end border-t border-gray-100 pt-5">
@if ($canInherit && $configured)
<button type="submit" name="mode" value="inherit" class="mr-4 settings-accent">恢复继承分组权限</button>
@endif
<button type="submit" class="rounded-lg settings-primary">保存权限</button>
</div>
</form>

View File

@ -9,6 +9,7 @@
<tbody>
<tr><td class="px-5 py-4 whitespace-nowrap">用户可访问页面</td><td class="px-5 py-4">设置用户或角色可以访问的业务页面。主管理员始终拥有全部页面权限。</td><td class="px-5 py-4 text-right whitespace-nowrap"><a class="settings-accent" href="{{ route('system-settings.sidebar.edit') }}">设置页面权限</a></td></tr>
@if (\App\Utilities\SystemAccess::isSuperAdmin())
<tr class="border-t border-gray-200"><td class="px-5 py-4 whitespace-nowrap">分组页面权限</td><td class="px-5 py-4">按岗位创建分组、添加成员,再统一设置业务页面。多个分组权限合并。</td><td class="px-5 py-4 text-right whitespace-nowrap"><a class="settings-accent" href="{{ route('system-settings.groups.index') }}">管理分组</a></td></tr>
<tr class="border-t border-gray-200"><td class="px-5 py-4 whitespace-nowrap">用户权限身份</td><td class="px-5 py-4">在用户管理中设置普通用户、管理员或主管理员身份。</td><td class="px-5 py-4 text-right whitespace-nowrap"><a class="settings-accent" href="{{ route('system-settings.users.index') }}">管理用户身份</a></td></tr>
@endif
</tbody>

View File

@ -2,6 +2,10 @@
<x-slot name="title">用户管理</x-slot>
<x-slot name="buttons">
@if (\App\Utilities\SystemAccess::isSuperAdmin())
<a href="{{ route('system-settings.groups.index') }}" class="settings-accent mr-4">用户分组</a>
<a href="{{ route('system-settings.groups.create') }}" class="settings-primary mr-3">创建分组</a>
@endif
<a href="{{ route('system-settings.users.create') }}" class="inline-flex items-center gap-2 rounded-lg settings-primary">
<span class="material-icons-outlined text-lg">person_add</span>
新增用户
@ -44,6 +48,9 @@
<tr>
<td class="px-5 py-4 font-semibold text-gray-800">
{{ $managedUser->name }}
@if (! empty($groupNamesByUser[$managedUser->id]))
<span class="block text-xs font-normal text-gray-600">分组:{{ implode('、', $groupNamesByUser[$managedUser->id]) }}</span>
@endif
@if ($managedUser->id === user()->id)
<span class="ml-2 inline-block whitespace-nowrap rounded-full bg-lilac-100 px-2 py-0.5 text-xs settings-accent">当前账号</span>
@endif

View File

@ -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');

View File

@ -0,0 +1,145 @@
<?php
namespace Tests\Feature\SystemSettings;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Auth\User;
use App\Models\Auth\UserAccessGroup;
use App\Utilities\SidebarPermissions;
use Tests\Feature\FeatureTestCase;
class UserAccessGroupsTest extends FeatureTestCase
{
public function test_create_group_and_add_members_without_granting_administrator_identity(): void
{
$member = $this->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;
}
}