用户管理布局与管理员身份权限设置 (#119)

This commit is contained in:
WuYongJie 2026-09-08 12:02:22 +08:00
parent 8dc9f3bc6f
commit e94d556125
28 changed files with 383 additions and 67 deletions

View File

@ -13,7 +13,7 @@ class Navigation extends Controller
parent::__construct();
$this->middleware(function ($request, $next) {
abort_unless(user()?->hasRole('admin'), 403);
abort_unless(\App\Utilities\SystemAccess::canOpenSettings(), 403);
return $next($request);
});

View File

@ -21,6 +21,11 @@ class Sidebar extends Controller
});
}
public function index()
{
return view('system_settings.sidebar.index');
}
public function edit(Request $request)
{
$subjects = $this->subjects();
@ -63,7 +68,7 @@ class Sidebar extends Controller
flash('保存成功,侧边栏权限已更新。')->success();
return redirect()->route('dashboard');
return redirect()->route('system-settings.sidebar.edit', ['subject' => $subject]);
}
private function subjects(): array
@ -72,7 +77,6 @@ class Sidebar extends Controller
if (SystemAccess::isSuperAdmin()) {
$subjects = [
'role_super_admin' => '用户类型:' . $labels[SystemAccess::SUPER_ADMIN],
'role_admin' => '用户类型:' . $labels[SystemAccess::ADMIN],
'role_user' => '用户类型:' . $labels[SystemAccess::USER],
];
@ -94,6 +98,9 @@ class Sidebar extends Controller
foreach ($users as $managedUser) {
$role = SystemAccess::roleName($managedUser);
if ($role === SystemAccess::SUPER_ADMIN) {
continue;
}
$subjects['user_' . $managedUser->id] = '指定用户:' . $managedUser->name . ' - ' . ($labels[$role] ?? $role);
}

View File

@ -87,12 +87,14 @@ class Users extends Controller
$role = Role::where('name', $data['role'])->firstOrFail();
if (
$managedUser->id === user()->id
&& SystemAccess::isSuperAdmin($managedUser)
&& $data['role'] !== SystemAccess::SUPER_ADMIN
SystemAccess::isSuperAdmin($managedUser)
&& $managedUser->enabled
&& ($data['role'] !== SystemAccess::SUPER_ADMIN || ! ($data['enabled'] ?? false))
&& $this->superAdminCount() <= 1
) {
return back()->withErrors(['role' => '系统至少需要保留一名超级管理员。'])->withInput();
return back()->withErrors([
$data['role'] !== SystemAccess::SUPER_ADMIN ? 'role' : 'enabled' => '系统至少需要保留一名启用的主管理员。',
])->withInput();
}
if ($managedUser->id === user()->id && ! ($data['enabled'] ?? false)) {
@ -111,9 +113,11 @@ class Users extends Controller
}
$managedUser->update($attributes);
$managedUser->roles()->sync([$role->id]);
$managedUser->syncRoles([$role->id]);
});
$managedUser->flushCache();
flash('用户已修改。')->success();
return redirect()->route('system-settings.users.index');
@ -126,8 +130,8 @@ class Users extends Controller
abort_if($managedUser->id === user()->id, 422, '不能删除当前登录账号。');
abort_unless(SystemAccess::canManageUser(user(), $managedUser), 403);
if (SystemAccess::isSuperAdmin($managedUser) && $this->superAdminCount() <= 1) {
return back()->withErrors(['user' => '系统至少需要保留一名超级管理员。']);
if (SystemAccess::isSuperAdmin($managedUser) && $managedUser->enabled && $this->superAdminCount() <= 1) {
return back()->withErrors(['user' => '系统至少需要保留一名启用的主管理员。']);
}
$managedUser->delete();
@ -143,10 +147,6 @@ class Users extends Controller
$company->where('companies.id', company_id());
});
if (! SystemAccess::isSuperAdmin()) {
$query->whereHas('roles', fn ($roles) => $roles->where('roles.name', SystemAccess::USER));
}
return $query;
}
@ -176,6 +176,7 @@ class Users extends Controller
private function superAdminCount(): int
{
return User::query()
->where('enabled', true)
->whereHas('roles', fn ($roles) => $roles->where('roles.name', SystemAccess::SUPER_ADMIN))
->count();
}

View File

@ -26,7 +26,7 @@ class Settings extends Component
$items = [
['system-settings.users.index', '用户管理', 'people', '用户 类型 权限'],
['system-settings.sidebar.edit', '侧边栏管理', 'menu', '侧边栏 用户类型 指定管理员'],
['system-settings.permissions.index', '权限管理', 'admin_panel_settings', '侧边栏 用户页面 访问权限 管理员 身份'],
['system-settings.databases.index', '数据库管理', 'storage', '系统数据库 云数据库 上游 连接 测试'],
];
@ -38,6 +38,8 @@ class Settings extends Component
$item = $menu->route($route, $title, [], ($index + 1) * 10, [
'icon' => $icon,
'search_keywords' => $keywords,
'active' => fn () => request()->routeIs(Str::beforeLast($route, '.') . '.*')
|| ($route === 'system-settings.permissions.index' && request()->routeIs('system-settings.sidebar.*')),
]);
if ($item->isActive()) {

View File

@ -14,6 +14,30 @@ class User extends FormRequest
*/
public function authorize()
{
$actor = $this->user();
if ($actor && ! \App\Utilities\SystemAccess::isSuperAdmin($actor)) {
$target = $this->route('user');
$target = $target instanceof \App\Models\Auth\User ? $target : \App\Models\Auth\User::find($target);
if ($target && $target->id !== $actor->id && \App\Utilities\SystemAccess::canOpenSettings($target)) {
return false;
}
if ($this->filled('permissions')) {
return false;
}
if ($this->has('roles')) {
$requested = array_map('strval', (array) $this->input('roles'));
$current = $target ? $target->roles()->pluck('id')->map(fn ($id) => (string) $id)->all() : [];
sort($requested);
sort($current);
if ($requested !== $current) {
$allowed = \App\Models\Auth\Role::where('name', 'user')->pluck('id')->map(fn ($id) => (string) $id)->all();
if (array_diff($requested, $allowed)) {
return false;
}
}
}
}
return true;
}

View File

@ -19,7 +19,7 @@ class ShowInSettings
{
$menu = $event->menu;
if (user()?->hasRole('admin')) {
if (\App\Utilities\SystemAccess::canOpenSettings()) {
$menu->route('settings.navigation.manage', '侧边栏功能名称', [], 75, [
'icon' => 'menu',
'search_keywords' => '侧边栏 功能 名称',

View File

@ -31,6 +31,10 @@ class SidebarPermissions
public static function allowedKeys(User $user): ?array
{
if (SystemAccess::isSuperAdmin($user)) {
return null;
}
$settings = self::settings();
$subject = self::subjectKey($user);
@ -57,7 +61,7 @@ class SidebarPermissions
return $section;
}
$pages = array_values(array_filter($section['pages'] ?? [], function (array $page) use ($allowed, $sectionKey) {
$pages = array_values(array_filter(array_map(function (array $page) use ($allowed, $sectionKey) {
$pageKey = $sectionKey . '/' . $page['slug'];
if (in_array($pageKey, $allowed, true)) {
@ -67,7 +71,7 @@ class SidebarPermissions
return in_array($sectionKey . '/' . $child['slug'], $allowed, true);
}));
}
return true;
return $page;
}
// 子页面可能在 allowed 列表中(独立 URL),父级分组也允许展开
@ -77,12 +81,12 @@ class SidebarPermissions
}));
if (! empty($visibleChildren)) {
$page['children'] = $visibleChildren;
return true;
return $page;
}
}
return false;
}));
return null;
}, $section['pages'] ?? [])));
if (! empty($pages)) {
$section['pages'] = $pages;

View File

@ -18,8 +18,11 @@ class SystemAccess
return '';
}
// 身份变更必须立即生效,不能沿用权限库中已缓存的旧角色。
$roles = $user->roles()->pluck('name')->all();
foreach ([self::SUPER_ADMIN, self::ADMIN, self::USER] as $role) {
if ($user->hasRole($role)) {
if (in_array($role, $roles, true)) {
return $role;
}
}
@ -53,8 +56,8 @@ class SystemAccess
if (self::isSuperAdmin($actor)) {
return [
self::SUPER_ADMIN => '超级管理员',
self::ADMIN => '普通管理员',
self::SUPER_ADMIN => '主管理员(最高权限)',
self::ADMIN => '管理员',
self::USER => '普通用户',
];
}
@ -65,8 +68,8 @@ class SystemAccess
public static function roleLabels(): array
{
return [
self::SUPER_ADMIN => '超级管理员',
self::ADMIN => '普通管理员',
self::SUPER_ADMIN => '主管理员',
self::ADMIN => '管理员',
self::USER => '普通用户',
];
}

View File

@ -0,0 +1,74 @@
@once
@push('scripts_start')
<script>
(() => {
if (window.caiwuSettingsNavigation) return;
window.caiwuSettingsNavigation = true;
let pending;
const supports = url => url.origin === location.origin
&& /\/system-settings\/(?:users(?:\/create|\/\d+\/edit)?|permissions|sidebar)$/.test(url.pathname);
async function navigate(url, push = true) {
const current = document.querySelector('[data-settings-workspace]');
if (!current) return;
pending?.abort();
const controller = new AbortController();
pending = controller;
current.setAttribute('aria-busy', 'true');
try {
const response = await fetch(url, {signal:controller.signal, credentials:'same-origin'});
const page = new DOMParser().parseFromString(await response.text(), 'text/html');
const next = page.querySelector('[data-settings-workspace]');
const header = page.querySelector('#header');
if (!response.ok || !next || !header) throw new Error('unavailable');
current.replaceWith(next);
document.querySelector('#header')?.replaceWith(header);
document.title = page.title;
document.querySelectorAll('#menu-settings a[id]').forEach(link => {
const updated = page.getElementById(link.id);
if (updated) link.className = updated.className;
});
if (push) {
history.replaceState({...history.state, settingsPage:true}, '', location.href);
history.pushState({settingsPage:true}, '', url);
}
const title = header.querySelector('h1');
title?.setAttribute('tabindex', '-1');
title?.focus({preventScroll:true});
window.scrollTo(0, 0);
} catch (error) {
if (error.name === 'AbortError') return;
let alert = current.querySelector('[data-settings-navigation-error]');
if (!alert) {
alert = document.createElement('p');
alert.dataset.settingsNavigationError = '';
alert.setAttribute('role', 'alert');
alert.className = 'mb-4 text-sm text-red-700';
current.prepend(alert);
}
alert.textContent = '页面暂时无法加载,请重试。';
} finally {
current.removeAttribute('aria-busy');
}
}
document.addEventListener('click', event => {
const link = event.target.closest('a[href]');
if (!link || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || link.target) return;
const url = new URL(link.href);
if (!document.querySelector('[data-settings-workspace]') || !supports(url)) return;
event.preventDefault();
navigate(url.href);
});
document.addEventListener('change', event => {
if (!event.target.matches('[data-settings-subject]')) return;
const form = event.target.form;
const url = new URL(form.action);
url.search = new URLSearchParams(new FormData(form)).toString();
navigate(url.href);
});
window.addEventListener('popstate', event => {
if (event.state?.settingsPage && supports(new URL(location.href))) navigate(location.href, false);
});
})();
</script>
@endpush
@endonce

View File

@ -1,21 +1,23 @@
<nav class="mb-6 flex flex-wrap items-center gap-3 rounded-xl border border-gray-200 bg-white p-3 shadow-sm" aria-label="系统设置">
@include('system_settings.partials.styles')
@include('system_settings.partials.navigation-script')
<nav class="mb-6 flex flex-wrap items-center gap-3 border-b border-gray-200 bg-white p-3" aria-label="系统设置">
@php
$items = [
['system-settings.users.index', '用户管理', 'people'],
['system-settings.dingtalk-bindings.index', '钉钉绑定', 'admin_panel_settings'],
['system-settings.sidebar.edit', '侧边栏管理', 'menu'],
['system-settings.permissions.index', '权限管理', 'admin_panel_settings'],
['system-settings.databases.index', '数据库管理', 'storage'],
];
@endphp
@foreach ($items as [$routeName, $label, $icon])
@php $active = request()->routeIs(Str::beforeLast($routeName, '.') . '.*'); @endphp
@php $active = request()->routeIs(Str::beforeLast($routeName, '.') . '.*') || ($routeName === 'system-settings.permissions.index' && request()->routeIs('system-settings.sidebar.*')); @endphp
<a
href="{{ route($routeName) }}"
@class([
'inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold transition-colors',
'bg-purple-700 text-white' => $active,
'text-gray-600 hover:bg-purple-100 hover:text-purple-700' => ! $active,
'settings-nav-active' => $active,
'text-gray-600 settings-nav-hover' => ! $active,
])
@if ($active) aria-current="page" @endif
>

View File

@ -0,0 +1,15 @@
<style>
#main-body > .container { max-width: none; width: auto; margin-left: 1.5rem; margin-right: 1.5rem; }
.settings-primary { display:inline-flex; align-items:center; justify-content:center; gap:8px; min-height:34px; padding:0 14px; border-radius:8px; background:var(--vip-content-accent-dark, #856632); color:#fff; font-size:13px; }
.settings-primary:hover { background:#6A5129; color:#fff; }
.settings-accent { color:var(--vip-content-accent-dark, #856632); }
.settings-nav-active { background:#F7F0DE; color:#856632; }
.settings-nav-hover:hover { background:#F7F0DE; color:#856632; }
.settings-field-focus:focus, .settings-primary:focus-visible { outline:2px solid #856632; outline-offset:2px; }
.settings-users-table { width:100%; min-width:680px; table-layout:fixed; }
.settings-users-table th { white-space:nowrap; }
.settings-users-table td { overflow-wrap:anywhere; }
.settings-users-table .settings-email { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
@media(min-width:1280px) { #main-body > .container { margin-left:2.5rem; margin-right:2.5rem; } }
@media(max-width:767px) { #main-body > .container { margin-left:1rem; margin-right:1rem; } }
</style>

View File

@ -1,18 +1,19 @@
<x-layouts.admin>
<x-slot name="title">侧边栏管理</x-slot>
<x-slot name="title">用户可访问页面</x-slot>
<x-slot name="content">
<div class="my-6">
<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.permissions.index') }}">权限管理</a><span class="mx-2" aria-hidden="true">/</span><span aria-current="page">用户可访问页面</span></nav>
<div class="mb-5 rounded-lg border-l-4 border-purple bg-lilac-100 px-4 py-3 text-sm text-gray-700">
先选择用户类型或指定用户,再勾选允许显示的一级、二级目录。指定用户配置优先于用户类型配置。
<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 shadow-sm">
<form method="GET" action="{{ route('system-settings.sidebar.edit') }}" class="mb-5 rounded-xl border border-gray-200 bg-white p-5">
<label class="block max-w-xl">
<span class="text-sm font-semibold text-gray-700">权限对象</span>
<select name="subject" onchange="this.form.submit()" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-purple focus:ring-purple">
<select name="subject" data-settings-subject class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm settings-field-focus">
@foreach ($subjects as $key => $label)
<option value="{{ $key }}" @selected($subject === $key)>{{ $label }}</option>
@endforeach
@ -20,7 +21,7 @@
</label>
</form>
<form method="POST" action="{{ route('system-settings.sidebar.update') }}" class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
<form method="POST" action="{{ route('system-settings.sidebar.update') }}" class="rounded-xl border border-gray-200 bg-white p-5">
@csrf
@method('PATCH')
<input type="hidden" name="subject" value="{{ $subject }}">
@ -33,7 +34,7 @@
<div class="mb-5 flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-800">{{ $subjects[$subject] }}</h2>
<h2 class="text-sm font-semibold text-gray-800">{{ $subjects[$subject] }}</h2>
<p class="mt-1 text-sm text-gray-500">{{ $configured ? '当前正在使用自定义权限。' : '当前未单独配置,默认显示全部目录。保存后将启用自定义权限。' }}</p>
</div>
<div class="flex gap-2">
@ -42,32 +43,32 @@
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
@foreach ($navigation as $section)
<details class="group rounded-xl border border-gray-200 bg-white">
<summary class="flex cursor-pointer list-none items-center gap-3 p-4 font-semibold text-gray-800">
<span class="material-icons-outlined text-xl text-purple">{{ $section['icon'] }}</span>
<span class="material-icons-outlined text-xl settings-accent">{{ $section['icon'] }}</span>
<span>{{ $section['title'] }}</span>
<span class="ml-auto text-xs font-normal text-gray-400">点击展开二级目录</span>
<span class="material-icons-outlined text-gray-400 transition-transform group-open:rotate-180">expand_more</span>
</summary>
<div class="border-t border-gray-100 p-4">
<label class="mb-3 flex items-center gap-2 rounded-lg bg-lilac-100 px-3 py-2 text-sm font-semibold text-purple">
<label class="mb-3 flex items-center gap-2 rounded-lg bg-lilac-100 px-3 py-2 text-sm font-semibold settings-accent">
<input
type="checkbox"
name="permissions[]"
value="{{ $section['slug'] }}"
data-sidebar-permission
@checked(in_array($section['slug'], $selected, true))
class="rounded border-gray-300 text-purple focus:ring-purple"
class="rounded border-gray-300 settings-accent settings-field-focus"
>
允许整个一级目录
</label>
@if (! empty($section['pages']))
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
@foreach ($section['pages'] as $page)
@foreach (collect($section['pages'])->flatMap(fn ($entry) => empty($entry['children']) ? [$entry] : $entry['children']) as $page)
@php $pageKey = $section['slug'] . '/' . $page['slug']; @endphp
<label class="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-gray-600 hover:bg-gray-50">
<input
@ -76,7 +77,7 @@
value="{{ $pageKey }}"
data-sidebar-permission
@checked(in_array($pageKey, $selected, true))
class="rounded border-gray-300 text-purple focus:ring-purple"
class="rounded border-gray-300 settings-accent settings-field-focus"
>
{{ $page['title'] }}
</label>
@ -91,7 +92,7 @@
</div>
<div class="mt-6 flex justify-end border-t border-gray-100 pt-5">
<button type="submit" class="rounded-lg bg-green px-5 py-2.5 text-sm text-white hover:bg-green-700">保存权限</button>
<button type="submit" class="rounded-lg settings-primary">保存权限</button>
</div>
</form>
</div>

View File

@ -0,0 +1,19 @@
<x-layouts.admin>
<x-slot name="title">权限管理</x-slot>
<x-slot name="content">
<div class="my-6" data-settings-workspace>
@include('system_settings.partials.navigation')
<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">设置项</th><th class="px-5 py-3">作用</th><th class="px-5 py-3 text-right">操作</th></tr></thead>
<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.users.index') }}">管理用户身份</a></td></tr>
@endif
</tbody>
</table>
</div>
</div>
</x-slot>
</x-layouts.admin>

View File

@ -2,10 +2,10 @@
<x-slot name="title">{{ $managedUser ? '修改用户' : '新增用户' }}</x-slot>
<x-slot name="content">
<div class="my-6">
<div class="my-6" data-settings-workspace>
@include('system_settings.partials.navigation')
<form method="POST" action="{{ $managedUser ? route('system-settings.users.update', $managedUser->id) : route('system-settings.users.store') }}" class="max-w-3xl rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<form method="POST" action="{{ $managedUser ? route('system-settings.users.update', $managedUser->id) : route('system-settings.users.store') }}" class="w-full rounded-xl border border-gray-200 bg-white p-6">
@csrf
@if ($managedUser) @method('PATCH') @endif
@ -18,41 +18,44 @@
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<label class="block">
<span class="text-sm font-semibold text-gray-700">姓名 *</span>
<input name="name" value="{{ old('name', $managedUser?->name) }}" required maxlength="191" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-purple focus:ring-purple">
<input name="name" value="{{ old('name', $managedUser?->name) }}" required maxlength="191" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm settings-field-focus">
</label>
<label class="block">
<span class="text-sm font-semibold text-gray-700">邮箱 *</span>
<input type="email" name="email" value="{{ old('email', $managedUser?->email) }}" required maxlength="191" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-purple focus:ring-purple">
<input type="email" name="email" value="{{ old('email', $managedUser?->email) }}" required maxlength="191" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm settings-field-focus">
</label>
<label class="block">
<span class="text-sm font-semibold text-gray-700">用户类型 *</span>
<select name="role" required class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-purple focus:ring-purple">
<span class="text-sm font-semibold text-gray-700">权限身份 *</span>
<select name="role" required class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm settings-field-focus">
@foreach ($roles as $role => $label)
<option value="{{ $role }}" @selected(old('role', $managedUser ? \App\Utilities\SystemAccess::roleName($managedUser) : \App\Utilities\SystemAccess::USER) === $role)>{{ $label }}</option>
@endforeach
</select>
@if (\App\Utilities\SystemAccess::isSuperAdmin())
<span class="mt-1 block text-xs text-gray-600">主管理员拥有最高权限,可管理全部用户,且不受页面访问范围限制。</span>
@endif
</label>
<div class="flex items-end pb-2">
<input type="hidden" name="enabled" value="0">
<label class="inline-flex items-center gap-2 text-sm font-semibold text-gray-700">
<input type="checkbox" name="enabled" value="1" @checked((bool) old('enabled', $managedUser?->enabled ?? true)) class="rounded border-gray-300 text-purple focus:ring-purple">
<input type="checkbox" name="enabled" value="1" @checked((bool) old('enabled', $managedUser?->enabled ?? true)) class="rounded border-gray-300 settings-accent settings-field-focus">
启用账号
</label>
</div>
<label class="block">
<span class="text-sm font-semibold text-gray-700">密码 {{ $managedUser ? '' : '*' }}</span>
<input type="password" name="password" @if (! $managedUser) required @endif minlength="8" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-purple focus:ring-purple">
<input type="password" name="password" @if (! $managedUser) required @endif minlength="8" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm settings-field-focus">
@if ($managedUser)<span class="mt-1 block text-xs text-gray-400">留空表示不修改密码</span>@endif
</label>
<label class="block">
<span class="text-sm font-semibold text-gray-700">确认密码 {{ $managedUser ? '' : '*' }}</span>
<input type="password" name="password_confirmation" @if (! $managedUser) required @endif minlength="8" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-purple focus:ring-purple">
<input type="password" name="password_confirmation" @if (! $managedUser) required @endif minlength="8" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm settings-field-focus">
</label>
</div>
<div class="mt-7 flex justify-end gap-3 border-t border-gray-100 pt-5">
<a href="{{ route('system-settings.users.index') }}" class="rounded-lg px-5 py-2.5 text-sm text-gray-600 hover:bg-gray-100">取消</a>
<button type="submit" class="rounded-lg bg-green px-5 py-2.5 text-sm text-white hover:bg-green-700">保存</button>
<button type="submit" class="rounded-lg settings-primary">保存</button>
</div>
</form>
</div>

View File

@ -2,21 +2,21 @@
<x-slot name="title">用户管理</x-slot>
<x-slot name="buttons">
<a href="{{ route('system-settings.users.create') }}" class="inline-flex items-center gap-2 rounded-lg bg-green px-5 py-2 text-sm text-white hover:bg-green-700">
<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>
新增用户
</a>
</x-slot>
<x-slot name="content">
<div class="my-6">
<div class="my-6" data-settings-workspace>
@include('system_settings.partials.navigation')
<div class="mb-4 rounded-lg border-l-4 border-purple bg-lilac-100 px-4 py-3 text-sm text-gray-700">
<div class="mb-4 rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
@if (\App\Utilities\SystemAccess::isSuperAdmin())
超级管理员可管理所有用户和用户类型。
主管理员拥有最高权限,可管理全部用户、设置管理员身份和页面访问范围。
@else
普通管理员只能新增、修改和删除普通用户。
已显示当前公司的全部账号。管理员可管理普通用户;管理员身份由主管理员设置。
@endif
</div>
@ -27,12 +27,13 @@
@endif
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<table class="settings-users-table divide-y divide-gray-200 text-sm">
<colgroup><col style="width:18%"><col style="width:32%"><col style="width:16%"><col style="width:10%"><col style="width:24%"></colgroup>
<thead class="bg-gray-50 text-left text-gray-500">
<tr>
<th class="px-5 py-3">姓名</th>
<th class="px-5 py-3">邮箱</th>
<th class="px-5 py-3">用户类型</th>
<th class="px-5 py-3">权限身份</th>
<th class="px-5 py-3">状态</th>
<th class="px-5 py-3 text-right">操作</th>
</tr>
@ -44,10 +45,10 @@
<td class="px-5 py-4 font-semibold text-gray-800">
{{ $managedUser->name }}
@if ($managedUser->id === user()->id)
<span class="ml-2 rounded-full bg-lilac-100 px-2 py-0.5 text-xs text-purple">当前账号</span>
<span class="ml-2 inline-block whitespace-nowrap rounded-full bg-lilac-100 px-2 py-0.5 text-xs settings-accent">当前账号</span>
@endif
</td>
<td class="px-5 py-4 text-gray-600">{{ $managedUser->email }}</td>
<td class="px-5 py-4 text-gray-600"><span class="settings-email" title="{{ $managedUser->email }}">{{ $managedUser->email }}</span></td>
<td class="px-5 py-4 text-gray-600">{{ $roleLabels[$roleName] ?? ($managedUser->roles->first()?->display_name ?? '-') }}</td>
<td class="px-5 py-4">
<span @class([
@ -58,7 +59,10 @@
</td>
<td class="px-5 py-4 text-right whitespace-nowrap">
@if (\App\Utilities\SystemAccess::canManageUser(user(), $managedUser))
<a href="{{ route('system-settings.users.edit', $managedUser->id) }}" class="mr-4 text-purple hover:underline">修改</a>
<a href="{{ route('system-settings.users.edit', $managedUser->id) }}" class="mr-4 settings-accent hover:underline">修改</a>
@if (in_array($roleName, [\App\Utilities\SystemAccess::ADMIN, \App\Utilities\SystemAccess::USER], true))
<a href="{{ route('system-settings.sidebar.edit', ['subject' => 'user_'.$managedUser->id]) }}" class="mr-4 settings-accent hover:underline">页面权限</a>
@endif
@if ($managedUser->id !== user()->id)
<form method="POST" action="{{ route('system-settings.users.destroy', $managedUser->id) }}" class="inline" onsubmit="return confirm('确定删除该用户吗?')">
@csrf

View File

@ -179,6 +179,7 @@ Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], functi
Route::post('dingtalk-bindings/{bindingRequest}/reject', 'SystemSettings\DingTalkBindings@reject')->name('dingtalk-bindings.reject');
Route::delete('dingtalk-bindings/identities/{identity}', 'SystemSettings\DingTalkBindings@destroy')->name('dingtalk-bindings.destroy');
Route::get('permissions', 'SystemSettings\Sidebar@index')->name('permissions.index');
Route::get('sidebar', 'SystemSettings\Sidebar@edit')->name('sidebar.edit');
Route::patch('sidebar', 'SystemSettings\Sidebar@update')->name('sidebar.update');

View File

@ -442,7 +442,7 @@ class DingTalkLoginTest extends FeatureTestCase
->assertOk()->assertSee('value="user_'.$created->id.'"', false);
$this->patch(route('system-settings.sidebar.update'), [
'subject' => 'user_'.$created->id, 'permissions' => ['store-management/basic-information'],
])->assertRedirect(route('dashboard'));
])->assertRedirect(route('system-settings.sidebar.edit', ['subject' => 'user_'.$created->id]));
$this->assertSame(['store-management/basic-information'], SidebarPermissions::allowedKeys($created));
$this->actingAs($created)->withSession(['dingtalk_identity_id' => $created->dingtalkIdentity->id, 'dingtalk_roster_checked_at' => now()->timestamp])
->get(route('dashboard'))->assertRedirectContains('/workspace/store-management/basic-information');
@ -453,6 +453,7 @@ class DingTalkLoginTest extends FeatureTestCase
public function test_explicit_empty_user_scope_does_not_fall_back_to_all_pages(): void
{
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', [
'configured' => ['user_'.$this->user->id], 'subjects' => ['user_'.$this->user->id => []],
]);

View File

@ -219,6 +219,7 @@ class AgreementSigningPageTest extends FeatureTestCase
{
$subject = 'user_' . $this->user->id;
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [

View File

@ -254,6 +254,7 @@ class CourierQuoteChangeWorkflowTest extends FeatureTestCase
public function test_user_without_quote_page_permission_cannot_use_workflow_routes(): void
{
$subject = 'user_' . $this->user->id;
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [$subject => ['courier-payables/freight-reconciliation']],

View File

@ -139,6 +139,7 @@ class ManagementCrudTest extends FeatureTestCase
public function testInstallationChildEndpointRequiresTheSameSidebarPermission(): void
{
$this->withExceptionHandling();
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', ['configured'=>['user_'.$this->user->id], 'subjects'=>['user_'.$this->user->id=>['store-management/basic-information']]]);
$mirror=Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldNotReceive('details');

View File

@ -308,6 +308,7 @@ class NonJingxiRefundPageTest extends FeatureTestCase
public function test_page_rejects_users_without_sidebar_permission(): void
{
$subject = 'user_' . $this->user->id;
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [

View File

@ -331,6 +331,7 @@ class ProductCatalogPageTest extends FeatureTestCase
{
$subject = 'user_' . $this->user->id;
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [
@ -348,6 +349,7 @@ class ProductCatalogPageTest extends FeatureTestCase
{
$subject = 'user_' . $this->user->id;
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [

View File

@ -52,6 +52,7 @@ class PrototypePageTest extends FeatureTestCase
public function testNavigationUsesConfiguredPermissionsAndTitles(): void
{
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', [
'configured' => ['user_' . $this->user->id],
'subjects' => ['user_' . $this->user->id => ['company-management/subject-operating-report']],

View File

@ -144,6 +144,7 @@ class QuoteManagementPageTest extends FeatureTestCase
{
$subject = 'user_' . $this->user->id;
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [

View File

@ -155,6 +155,7 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase
public function test_page_rejects_users_without_sidebar_permission(): void
{
$subject = 'user_' . $this->user->id;
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [$subject => ['company-management/basic-information']],

View File

@ -77,6 +77,7 @@ class StoreAccountPasswordsTest extends FeatureTestCase
public function test_restricted_admin_cannot_bypass_store_page_scope(): void
{
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', ['configured' => ['user_'.$this->user->id], 'subjects' => ['user_'.$this->user->id => []]]);
$this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal');
$this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertForbidden();

View File

@ -250,6 +250,7 @@ class SupplierPayablePagesTest extends FeatureTestCase
{
$subject = 'user_' . $this->user->id;
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [

View File

@ -0,0 +1,144 @@
<?php
namespace Tests\Feature\SystemSettings;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Auth\User;
use App\Utilities\BusinessNavigation;
use App\Utilities\SidebarPermissions;
use App\Utilities\SystemAccess;
use Tests\Feature\FeatureTestCase;
class UserPermissionsTest extends FeatureTestCase
{
public function test_admin_sees_all_company_accounts_but_cannot_edit_administrators(): void
{
$admin = $this->account('admin');
$regular = $this->account('user');
$outside = $this->account('user');
$outside->companies()->detach();
$this->loginAs($admin)->get(route('system-settings.users.index'))
->assertOk()->assertSeeText($this->user->name)->assertSeeText($admin->name)->assertSeeText($regular->name)
->assertDontSeeText($outside->name)
->assertDontSee(route('system-settings.users.edit', $this->user->id), false)
->assertSee(route('system-settings.users.edit', $regular->id), false);
$this->withExceptionHandling()->get(route('system-settings.users.edit', $this->user->id))->assertForbidden();
$this->patch(route('system-settings.users.update', $this->user->id), $this->payload($this->user, 'user'))->assertForbidden();
$this->delete(route('system-settings.users.destroy', $this->user->id))->assertForbidden();
}
public function test_super_admin_can_assign_both_administrator_roles(): void
{
$target = $this->account('user');
$this->loginAs()->get(route('system-settings.users.edit', $target->id))->assertOk()
->assertSeeText('主管理员(最高权限)')->assertSee('value="admin"', false);
foreach (['admin', 'super_admin'] as $role) {
$this->patch(route('system-settings.users.update', $target->id), $this->payload($target, $role))
->assertRedirect(route('system-settings.users.index'));
$target = $target->fresh();
$this->assertSame([$role], $target->roles()->pluck('name')->all());
}
setting()->set('system.sidebar_permissions', ['configured' => ['user_'.$target->id], 'subjects' => ['user_'.$target->id => []]]);
$this->assertSame(BusinessNavigation::all(), SidebarPermissions::navigationFor($target));
$this->assertTrue(SystemAccess::isSuperAdmin($target));
$this->loginAs($target)->get(route('system-settings.users.index'))->assertOk();
$this->get(route('settings.navigation.manage'))->assertOk();
}
public function test_admin_cannot_promote_a_user_or_itself(): void
{
$admin = $this->account('admin');
$target = $this->account('user');
$this->withExceptionHandling()->loginAs($admin);
foreach (['admin', 'super_admin'] as $role) {
$this->patchJson(route('system-settings.users.update', $target->id), $this->payload($target, $role))
->assertUnprocessable()->assertJsonValidationErrors('role');
}
$this->patchJson(route('system-settings.users.update', $admin->id), $this->payload($admin, 'super_admin'))->assertForbidden();
$this->assertSame(['user'], $target->roles()->pluck('name')->all());
}
public function test_demotion_removes_cached_administrator_access(): void
{
$target = $this->account('super_admin');
$this->assertTrue($target->hasRole('super_admin'));
$this->assertTrue($target->can('update-auth-users'));
$this->loginAs()->patch(route('system-settings.users.update', $target->id), $this->payload($target, 'user'))
->assertRedirect(route('system-settings.users.index'));
$this->assertSame('user', SystemAccess::roleName($target));
$this->assertFalse($target->fresh()->can('update-auth-users'));
$this->withExceptionHandling()->loginAs($target)->get(route('system-settings.users.index'))->assertForbidden();
}
public function test_legacy_user_routes_cannot_grant_administrator_identity(): void
{
$admin = $this->account('admin');
$target = $this->account('user');
$this->withExceptionHandling()->loginAs($admin);
$role = (string) Role::where('name', 'super_admin')->value('id');
$this->patchJson(route('users.update', $target->id), ['roles' => $role])->assertForbidden();
$this->patchJson(route('profile.update', $admin->id), ['roles' => $role])->assertForbidden();
$this->postJson(route('users.store'), ['roles' => $role])->assertForbidden();
$this->patchJson(route('users.update', $this->user->id), ['name' => 'unauthorized'])->assertForbidden();
}
public function test_last_super_admin_cannot_be_demoted_or_disabled(): void
{
$this->loginAs();
$this->patch(route('system-settings.users.update', $this->user->id), $this->payload($this->user, 'admin'))
->assertSessionHasErrors('role');
$this->patch(route('system-settings.users.update', $this->user->id), array_merge($this->payload($this->user, 'super_admin'), ['enabled' => 0]))
->assertSessionHasErrors('enabled');
$this->assertSame(['super_admin'], $this->user->roles()->pluck('name')->all());
}
public function test_user_page_settings_are_a_second_level_page_and_save_for_selected_user(): void
{
$target = $this->account('user');
$this->loginAs()->get(route('system-settings.permissions.index'))->assertOk()
->assertSeeText('用户可访问页面')->assertSee(route('system-settings.sidebar.edit'), false);
$this->get(route('system-settings.sidebar.edit', ['subject' => 'user_'.$target->id]))->assertOk()
->assertSee('aria-label="页面位置"', false)->assertSeeText('主体商标规则');
$this->patch(route('system-settings.sidebar.update'), [
'subject' => 'user_'.$target->id, 'permissions' => ['store-management/basic-information'],
])->assertRedirect(route('system-settings.sidebar.edit', ['subject' => 'user_'.$target->id]));
$this->assertSame(['store-management/basic-information'], SidebarPermissions::allowedKeys($target));
$this->get(route('system-settings.sidebar.edit', ['subject' => 'user_'.$target->id]))->assertOk()->assertSeeText('自定义权限');
}
public function test_super_admin_cannot_be_limited_by_page_settings_and_regular_users_cannot_configure_access(): void
{
$this->withExceptionHandling()->loginAs()->patchJson(route('system-settings.sidebar.update'), [
'subject' => 'user_'.$this->user->id, 'permissions' => [],
])->assertUnprocessable();
$regular = $this->account('user');
$this->loginAs($regular)->get(route('system-settings.permissions.index'))->assertForbidden();
$this->get(route('system-settings.users.index'))->assertForbidden();
$this->patchJson(route('system-settings.sidebar.update'), ['subject' => 'role_user', 'permissions' => []])->assertForbidden();
}
public function test_child_page_navigation_contains_only_the_selected_child(): void
{
$regular = $this->account('user');
setting()->set('system.sidebar_permissions', ['configured' => ['user_'.$regular->id], 'subjects' => ['user_'.$regular->id => ['trademark-management/subject-trademark-rules']]]);
$navigation = SidebarPermissions::navigationFor($regular);
$pages = collect($navigation)->flatMap(fn ($section) => $section['pages'])->flatMap(fn ($page) => $page['children'] ?? [$page]);
$this->assertSame(['subject-trademark-rules'], $pages->pluck('slug')->all());
}
private function account(string $role): User
{
$name = 'fixture-'.$role.'-'.User::count();
$account = User::create(['name' => $name, 'email' => $name.'@example.test', 'password' => 'fixture-unused-password', 'enabled' => true, 'locale' => 'zh-CN', 'landing_page' => 'dashboard']);
$account->roles()->attach(Role::where('name', $role)->firstOrFail()->id);
$account->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
$account->companies()->attach($this->company->id);
return $account;
}
private function payload(User $target, string $role): array
{
return ['name' => $target->name, 'email' => $target->email, 'role' => $role, 'enabled' => 1];
}
}