Browse Source

Add role permission management

Alexander Musikhin 1 week ago
parent
commit
7558120bd5

+ 46 - 0
app/Helpers/roles.php

@@ -1,11 +1,57 @@
 <?php
 <?php
 
 
 use App\Models\Role;
 use App\Models\Role;
+use Illuminate\Database\QueryException;
+use Illuminate\Support\Facades\Schema;
 
 
 if(!function_exists('getRoles')){
 if(!function_exists('getRoles')){
     function getRoles($key = null): array|string
     function getRoles($key = null): array|string
     {
     {
         $roles = Role::NAMES;
         $roles = Role::NAMES;
+
+        try {
+            if (Schema::hasTable('roles')) {
+                $roles = Role::query()
+                    ->where('is_active', true)
+                    ->orderBy('sort')
+                    ->orderBy('name')
+                    ->pluck('name', 'slug')
+                    ->all() ?: $roles;
+            }
+        } catch (QueryException) {
+            $roles = Role::NAMES;
+        }
+
+        if($key && isset($roles[$key])){
+            return $roles[$key];
+        } else {
+            return $roles;
+        }
+    }
+}
+
+if(!function_exists('getRoleIdOptions')){
+    function getRoleIdOptions($key = null): array|string
+    {
+        $roles = [];
+
+        try {
+            if (Schema::hasTable('roles')) {
+                $roles = Role::query()
+                    ->where('is_active', true)
+                    ->orderBy('sort')
+                    ->orderBy('name')
+                    ->pluck('name', 'id')
+                    ->all();
+            }
+        } catch (QueryException) {
+            $roles = [];
+        }
+
+        if (!$roles) {
+            $roles = getRoles();
+        }
+
         if($key && isset($roles[$key])){
         if($key && isset($roles[$key])){
             return $roles[$key];
             return $roles[$key];
         } else {
         } else {

+ 220 - 0
app/Http/Controllers/Admin/RoleController.php

@@ -0,0 +1,220 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Http\Controllers\Controller;
+use App\Models\Permission;
+use App\Models\Role;
+use App\Models\User;
+use App\Services\Access\AccessService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+use Illuminate\View\View;
+
+class RoleController extends Controller
+{
+    public function index(): View
+    {
+        return view('admin.roles.index', [
+            'active' => 'roles',
+            'title' => 'Роли и права',
+            'roles' => Role::query()
+                ->withCount(['users' => fn ($query) => $query->withTrashed()])
+                ->orderBy('sort')
+                ->orderBy('name')
+                ->get(),
+        ]);
+    }
+
+    public function create(): View
+    {
+        return view('admin.roles.edit', $this->formData(new Role(), []));
+    }
+
+    public function store(Request $request, AccessService $accessService): RedirectResponse
+    {
+        $validated = $this->validateRole($request);
+
+        $role = DB::transaction(function () use ($validated, $request, $accessService) {
+            $role = Role::query()->create([
+                'slug' => $validated['slug'],
+                'name' => $validated['name'],
+                'description' => $validated['description'] ?? null,
+                'is_system' => false,
+                'is_active' => (bool) ($validated['is_active'] ?? true),
+                'sort' => (int) ($validated['sort'] ?? 100),
+            ]);
+
+            $this->syncRolePermissions($role, $request->input('permission_effects', []));
+            $accessService->bumpCacheVersion();
+
+            return $role;
+        });
+
+        return redirect()
+            ->route('admin.roles.edit', $role)
+            ->with('success', 'Роль создана.');
+    }
+
+    public function storeFromUser(User $user, Request $request, AccessService $accessService): RedirectResponse
+    {
+        $validated = $request->validate([
+            'name' => ['required', 'string', 'min:2', 'max:255'],
+            'slug' => ['nullable', 'string', 'alpha_dash', 'max:80', 'unique:roles,slug'],
+            'description' => ['nullable', 'string', 'max:1000'],
+        ]);
+
+        $slug = $validated['slug'] ?: Str::slug($validated['name']);
+        if (!$slug) {
+            $slug = 'role_' . $user->id;
+        }
+
+        $baseSlug = $slug;
+        $counter = 2;
+        while (Role::query()->where('slug', $slug)->exists()) {
+            $slug = $baseSlug . '_' . $counter++;
+        }
+
+        $role = DB::transaction(function () use ($validated, $slug, $user, $accessService) {
+            $role = Role::query()->create([
+                'slug' => $slug,
+                'name' => $validated['name'],
+                'description' => $validated['description'] ?? 'Создана из совокупности прав пользователя ' . $user->name,
+                'is_system' => false,
+                'is_active' => true,
+                'sort' => 100,
+            ]);
+
+            $sync = [];
+            foreach ($accessService->getEffectivePermissions($user) as $permissionSlug => $effect) {
+                $sync[$permissionSlug] = $effect;
+            }
+
+            $this->syncRolePermissions($role, $sync);
+            $accessService->bumpCacheVersion();
+
+            return $role;
+        });
+
+        return redirect()
+            ->route('admin.roles.edit', $role)
+            ->with('success', 'Роль создана из прав пользователя.');
+    }
+
+    public function edit(Role $role): View
+    {
+        $effects = $role->permissions()
+            ->pluck('role_permissions.effect', 'permissions.id')
+            ->all();
+
+        return view('admin.roles.edit', $this->formData($role, $effects));
+    }
+
+    public function update(Role $role, Request $request, AccessService $accessService): RedirectResponse
+    {
+        $validated = $this->validateRole($request, $role);
+
+        DB::transaction(function () use ($role, $validated, $request, $accessService) {
+            $role->update([
+                'name' => $validated['name'],
+                'description' => $validated['description'] ?? null,
+                'is_active' => $role->slug === Role::ADMIN ? true : (bool) ($validated['is_active'] ?? false),
+                'sort' => (int) ($validated['sort'] ?? $role->sort),
+            ]);
+
+            if ($role->slug === Role::ADMIN) {
+                $this->syncRolePermissions(
+                    $role,
+                    Permission::query()->pluck('slug')->mapWithKeys(fn (string $slug): array => [$slug => 'allow'])->all()
+                );
+            } else {
+                $this->syncRolePermissions($role, $request->input('permission_effects', []));
+            }
+
+            $accessService->bumpCacheVersion();
+        });
+
+        return redirect()
+            ->route('admin.roles.edit', $role)
+            ->with('success', 'Роль сохранена.');
+    }
+
+    public function destroy(Role $role, AccessService $accessService): RedirectResponse
+    {
+        if ($role->slug === Role::ADMIN) {
+            return redirect()
+                ->route('admin.roles.index')
+                ->with('danger', 'Роль администратора нельзя удалить.');
+        }
+
+        $linkedUsers = User::withTrashed()
+            ->where('role_id', $role->id)
+            ->orWhere('role', $role->slug)
+            ->count();
+
+        if ($linkedUsers > 0) {
+            return redirect()
+                ->route('admin.roles.edit', $role)
+                ->with('danger', 'Роль нельзя удалить, пока на неё ссылается хотя бы один пользователь.');
+        }
+
+        $role->delete();
+        $accessService->bumpCacheVersion();
+
+        return redirect()
+            ->route('admin.roles.index')
+            ->with('success', 'Роль удалена.');
+    }
+
+    private function formData(Role $role, array $effects): array
+    {
+        return [
+            'active' => 'roles',
+            'title' => $role->exists ? 'Редактирование роли' : 'Создание роли',
+            'role' => $role,
+            'permissionGroups' => Permission::getGroupedForUi(),
+            'permissionEffects' => $effects,
+        ];
+    }
+
+    private function validateRole(Request $request, ?Role $role = null): array
+    {
+        $roleId = $role?->id ?: 'NULL';
+
+        return $request->validate([
+            'slug' => [
+                $role?->exists ? 'nullable' : 'required',
+                'string',
+                'alpha_dash',
+                'max:80',
+                'unique:roles,slug,' . $roleId,
+            ],
+            'name' => ['required', 'string', 'min:2', 'max:255'],
+            'description' => ['nullable', 'string', 'max:1000'],
+            'is_active' => ['nullable', 'boolean'],
+            'sort' => ['nullable', 'integer', 'min:0', 'max:100000'],
+            'permission_effects' => ['nullable', 'array'],
+            'permission_effects.*' => ['nullable', 'in:none,allow,deny'],
+        ]);
+    }
+
+    private function syncRolePermissions(Role $role, array $effects): void
+    {
+        $permissions = Permission::query()
+            ->whereIn('id', array_filter(array_keys($effects), 'is_numeric'))
+            ->orWhereIn('slug', array_filter(array_keys($effects), fn ($key): bool => !is_numeric($key)))
+            ->get();
+
+        $sync = [];
+        foreach ($permissions as $permission) {
+            $effect = $effects[$permission->id] ?? $effects[$permission->slug] ?? null;
+            if (in_array($effect, ['allow', 'deny'], true)) {
+                $sync[$permission->id] = ['effect' => $effect];
+            }
+        }
+
+        $role->permissions()->sync($sync);
+    }
+}

+ 87 - 14
app/Http/Controllers/UserController.php

@@ -6,12 +6,16 @@ use App\Http\Requests\User\DeleteUser;
 use App\Http\Requests\User\StoreProfile;
 use App\Http\Requests\User\StoreProfile;
 use App\Http\Requests\User\StoreUser;
 use App\Http\Requests\User\StoreUser;
 use App\Models\Order;
 use App\Models\Order;
+use App\Models\Permission;
 use App\Models\Reclamation;
 use App\Models\Reclamation;
 use App\Models\ReclamationStatus;
 use App\Models\ReclamationStatus;
+use App\Models\Role;
 use App\Models\User;
 use App\Models\User;
 use App\Models\UserNotificationSetting;
 use App\Models\UserNotificationSetting;
+use App\Services\Access\AccessService;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Hash;
 
 
 class UserController extends Controller
 class UserController extends Controller
@@ -85,6 +89,7 @@ class UserController extends Controller
         );
         );
         $this->data['user'] = null;
         $this->data['user'] = null;
         $this->prepareNotificationSettingsData(null);
         $this->prepareNotificationSettingsData(null);
+        $this->preparePermissionSettingsData(null);
         return view('users.edit', $this->data);
         return view('users.edit', $this->data);
     }
     }
 
 
@@ -95,8 +100,18 @@ class UserController extends Controller
     {
     {
         $validated = $request->validated();
         $validated = $request->validated();
         $settingsData = $this->extractNotificationSettings($request);
         $settingsData = $this->extractNotificationSettings($request);
+        $permissionEffects = $validated['permission_effects'] ?? [];
 
 
         unset($validated['notification_settings']);
         unset($validated['notification_settings']);
+        unset($validated['permission_effects']);
+
+        if (!empty($validated['role_id'])) {
+            $role = Role::query()->find($validated['role_id']);
+            $validated['role'] = $role?->slug ?? $validated['role'] ?? Role::MANAGER;
+        } elseif (!empty($validated['role'])) {
+            $role = Role::query()->where('slug', $validated['role'])->first();
+            $validated['role_id'] = $role?->id;
+        }
 
 
         if(!empty($validated['password'])) {
         if(!empty($validated['password'])) {
             $validated['password'] = Hash::make($validated['password']);
             $validated['password'] = Hash::make($validated['password']);
@@ -105,21 +120,27 @@ class UserController extends Controller
         }
         }
 
 
         $user = null;
         $user = null;
-        if(isset($validated['id'])) {
-            User::query()
-                ->where('id', $validated['id'])
-                ->update($validated);
-            $user = User::query()->find($validated['id']);
-        } else {
-            $user = User::query()->create($validated);
-        }
+        DB::transaction(function () use ($validated, $settingsData, $permissionEffects, &$user) {
+            if(isset($validated['id'])) {
+                User::query()
+                    ->where('id', $validated['id'])
+                    ->update($validated);
+                $user = User::query()->find($validated['id']);
+            } else {
+                $user = User::query()->create($validated);
+            }
 
 
-        if ($user) {
-            UserNotificationSetting::query()->updateOrCreate(
-                ['user_id' => $user->id],
-                $settingsData,
-            );
-        }
+            if ($user) {
+                UserNotificationSetting::query()->updateOrCreate(
+                    ['user_id' => $user->id],
+                    $settingsData,
+                );
+
+                $this->syncUserPermissionOverrides($user, $permissionEffects);
+            }
+        });
+
+        app(AccessService::class)->bumpCacheVersion();
 
 
         return redirect()->route('user.index')->with(['success' => 'Пользователь ' . $validated['name'] . ' сохранён!']);
         return redirect()->route('user.index')->with(['success' => 'Пользователь ' . $validated['name'] . ' сохранён!']);
     }
     }
@@ -142,6 +163,7 @@ class UserController extends Controller
             ->withTrashed()
             ->withTrashed()
             ->first();
             ->first();
         $this->prepareNotificationSettingsData($this->data['user']);
         $this->prepareNotificationSettingsData($this->data['user']);
+        $this->preparePermissionSettingsData($this->data['user']);
 
 
         return view('users.edit', $this->data);
         return view('users.edit', $this->data);
     }
     }
@@ -280,6 +302,57 @@ class UserController extends Controller
         $this->data['notificationSettings'] = $settings->toArray();
         $this->data['notificationSettings'] = $settings->toArray();
     }
     }
 
 
+    private function preparePermissionSettingsData(?User $user): void
+    {
+        $this->data['roles'] = Role::query()
+            ->where('is_active', true)
+            ->orderBy('sort')
+            ->orderBy('name')
+            ->pluck('name', 'id')
+            ->all();
+
+        $this->data['permissionGroups'] = Permission::getGroupedForUi();
+        $this->data['permissionEffects'] = [];
+        $this->data['rolePermissionEffects'] = [];
+
+        if (!$user) {
+            return;
+        }
+
+        $this->data['permissionEffects'] = $user->permissions()
+            ->pluck('user_permissions.effect', 'permissions.id')
+            ->all();
+
+        if ($user->roleModel) {
+            $this->data['rolePermissionEffects'] = $user->roleModel
+                ->permissions()
+                ->pluck('role_permissions.effect', 'permissions.id')
+                ->all();
+        }
+    }
+
+    private function syncUserPermissionOverrides(User $user, array $effects): void
+    {
+        if ($user->resolvedRoleSlug() === Role::ADMIN) {
+            $user->permissions()->detach();
+            return;
+        }
+
+        $permissions = Permission::query()
+            ->whereIn('id', array_filter(array_keys($effects), 'is_numeric'))
+            ->get();
+
+        $sync = [];
+        foreach ($permissions as $permission) {
+            $effect = $effects[$permission->id] ?? null;
+            if (in_array($effect, ['allow', 'deny'], true)) {
+                $sync[$permission->id] = ['effect' => $effect];
+            }
+        }
+
+        $user->permissions()->sync($sync);
+    }
+
     private function extractNotificationSettings(Request $request): array
     private function extractNotificationSettings(Request $request): array
     {
     {
         $input = $request->input('notification_settings', []);
         $input = $request->input('notification_settings', []);

+ 3 - 0
app/Http/Requests/User/StoreUser.php

@@ -31,10 +31,13 @@ class StoreUser extends FormRequest
             'phone'     => 'nullable|string',
             'phone'     => 'nullable|string',
             'password'  => 'required_without:id|nullable|string|min:4',
             'password'  => 'required_without:id|nullable|string|min:4',
             'role'      => 'nullable|string|in:' . implode(',', Role::VALID_ROLES),
             'role'      => 'nullable|string|in:' . implode(',', Role::VALID_ROLES),
+            'role_id'   => 'nullable|integer|exists:roles,id',
             'color'     => 'nullable|string',
             'color'     => 'nullable|string',
             'notification_email' => 'nullable|email',
             'notification_email' => 'nullable|email',
 
 
             'notification_settings' => 'nullable|array',
             'notification_settings' => 'nullable|array',
+            'permission_effects' => 'nullable|array',
+            'permission_effects.*' => 'nullable|in:none,allow,deny',
         ];
         ];
     }
     }
 
 

+ 107 - 0
resources/views/admin/roles/edit.blade.php

@@ -0,0 +1,107 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-2">
+        <div class="col-md-7">
+            <h3>{{ $role->exists ? 'Роль: ' . $role->name : 'Новая роль' }}</h3>
+        </div>
+        <div class="col-md-5 text-end">
+            <a href="{{ route('admin.roles.index') }}" class="btn btn-sm btn-outline-secondary">Назад</a>
+        </div>
+    </div>
+
+    <form action="{{ $role->exists ? route('admin.roles.update', $role) : route('admin.roles.store') }}" method="post">
+        @csrf
+        @if($role->exists)
+            @method('PUT')
+        @endif
+
+        <div class="row">
+            <div class="col-lg-5 col-xl-4">
+                @include('partials.input', [
+                    'name' => 'name',
+                    'title' => 'Название',
+                    'required' => true,
+                    'value' => old('name', $role->name),
+                ])
+
+                @include('partials.input', [
+                    'name' => 'slug',
+                    'title' => 'Код',
+                    'required' => !$role->exists,
+                    'value' => old('slug', $role->slug),
+                    'disabled' => $role->exists,
+                ])
+                @if($role->exists)
+                    <input type="hidden" name="slug" value="{{ $role->slug }}">
+                @endif
+
+                @include('partials.input', [
+                    'name' => 'description',
+                    'title' => 'Описание',
+                    'value' => old('description', $role->description),
+                ])
+
+                @include('partials.input', [
+                    'name' => 'sort',
+                    'type' => 'number',
+                    'title' => 'Сортировка',
+                    'value' => old('sort', $role->sort ?? 100),
+                ])
+
+                <div class="row mb-2">
+                    <label class="col-form-label small col-md-4 text-md-end" for="is_active">Активна</label>
+                    <div class="col-md-8">
+                        <input type="hidden" name="is_active" value="0">
+                        <div class="form-check mt-2">
+                            <input class="form-check-input" type="checkbox" name="is_active" value="1" id="is_active"
+                                   @checked(old('is_active', $role->exists ? $role->is_active : true))
+                                   @disabled($role->slug === \App\Models\Role::ADMIN)>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="text-end mb-3">
+                    <button type="submit" class="btn btn-sm btn-primary">Сохранить</button>
+                </div>
+            </div>
+
+            <div class="col-lg-7 col-xl-8">
+                @if($role->slug === \App\Models\Role::ADMIN)
+                    <div class="alert alert-warning py-2">
+                        Администратору всегда доступны все права. Убирать права или ставить deny нельзя.
+                    </div>
+                @endif
+
+                @include('admin.roles.partials.permissions-table', [
+                    'permissionGroups' => $permissionGroups,
+                    'permissionEffects' => $permissionEffects,
+                    'inputName' => 'permission_effects',
+                    'adminLocked' => $role->slug === \App\Models\Role::ADMIN,
+                    'inheritLabel' => 'Нет',
+                ])
+            </div>
+        </div>
+    </form>
+
+    @if($role->exists && $role->slug !== \App\Models\Role::ADMIN)
+        <form action="{{ route('admin.roles.destroy', $role) }}" method="post" class="d-none" id="delete-role">
+            @csrf
+            @method('DELETE')
+        </form>
+
+        <div class="text-end mt-3">
+            <a href="#" class="btn btn-sm btn-outline-danger delete-role">Удалить роль</a>
+        </div>
+    @endif
+@endsection
+
+@push('scripts')
+    <script type="module">
+        $('.delete-role').on('click', function () {
+            customConfirm('Удалить роль? Это возможно только если на неё не ссылается ни один пользователь.', function () {
+                $('#delete-role').submit();
+            });
+        });
+    </script>
+@endpush

+ 46 - 0
resources/views/admin/roles/index.blade.php

@@ -0,0 +1,46 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-2">
+        <div class="col-md-6">
+            <h3>Роли и права</h3>
+        </div>
+        <div class="col-md-6 text-end">
+            <a href="{{ route('admin.roles.create') }}" class="btn btn-sm btn-primary">Добавить роль</a>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-sm table-hover align-middle">
+            <thead>
+            <tr>
+                <th>Роль</th>
+                <th>Код</th>
+                <th>Тип</th>
+                <th>Активна</th>
+                <th>Пользователей</th>
+                <th></th>
+            </tr>
+            </thead>
+            <tbody>
+            @foreach($roles as $role)
+                <tr>
+                    <td>
+                        <a href="{{ route('admin.roles.edit', $role) }}">{{ $role->name }}</a>
+                        @if($role->description)
+                            <div class="text-muted small">{{ $role->description }}</div>
+                        @endif
+                    </td>
+                    <td><code>{{ $role->slug }}</code></td>
+                    <td>{{ $role->is_system ? 'Базовая' : 'Пользовательская' }}</td>
+                    <td>{{ $role->is_active ? 'Да' : 'Нет' }}</td>
+                    <td>{{ $role->users_count }}</td>
+                    <td class="text-end">
+                        <a href="{{ route('admin.roles.edit', $role) }}" class="btn btn-sm btn-outline-primary">Открыть</a>
+                    </td>
+                </tr>
+            @endforeach
+            </tbody>
+        </table>
+    </div>
+@endsection

+ 65 - 0
resources/views/admin/roles/partials/permissions-table.blade.php

@@ -0,0 +1,65 @@
+@php
+    $effects = old($inputName, $permissionEffects ?? []);
+    $adminLocked = $adminLocked ?? false;
+    $inheritLabel = $inheritLabel ?? 'Наследовать';
+@endphp
+
+<div class="accordion" id="permissionsAccordion">
+    @foreach($permissionGroups as $group => $permissions)
+        @php($groupId = 'permissions-' . \Illuminate\Support\Str::slug($group ?: 'other') . '-' . $loop->index)
+        <div class="accordion-item">
+            <h2 class="accordion-header" id="{{ $groupId }}-heading">
+                <button class="accordion-button @if(!$loop->first) collapsed @endif" type="button"
+                        data-bs-toggle="collapse"
+                        data-bs-target="#{{ $groupId }}"
+                        aria-expanded="{{ $loop->first ? 'true' : 'false' }}"
+                        aria-controls="{{ $groupId }}">
+                    {{ $group ?: 'Прочее' }}
+                    <span class="text-muted small ms-2">{{ $permissions->count() }}</span>
+                </button>
+            </h2>
+            <div id="{{ $groupId }}" class="accordion-collapse collapse @if($loop->first) show @endif"
+                 aria-labelledby="{{ $groupId }}-heading">
+                <div class="accordion-body p-0">
+                    <div class="table-responsive">
+                        <table class="table table-sm align-middle mb-0">
+                            <thead>
+                            <tr>
+                                <th>Право</th>
+                                <th>Код</th>
+                                <th class="text-end">Доступ</th>
+                            </tr>
+                            </thead>
+                            <tbody>
+                            @foreach($permissions as $permission)
+                                @php($value = $adminLocked ? 'allow' : ($effects[$permission->id] ?? 'none'))
+                                <tr>
+                                    <td>
+                                        {{ $permission->name }}
+                                        @if($permission->type === \App\Models\Permission::TYPE_FIELD)
+                                            <span class="badge text-bg-light">поле</span>
+                                        @endif
+                                    </td>
+                                    <td><code>{{ $permission->slug }}</code></td>
+                                    <td class="text-end" style="min-width: 180px;">
+                                        <select name="{{ $inputName }}[{{ $permission->id }}]"
+                                                class="form-select form-select-sm"
+                                                @disabled($adminLocked)>
+                                            <option value="none" @selected($value === 'none')>{{ $inheritLabel }}</option>
+                                            <option value="allow" @selected($value === 'allow')>Разрешить</option>
+                                            <option value="deny" @selected($value === 'deny') @disabled($adminLocked)>Запретить</option>
+                                        </select>
+                                        @if($adminLocked)
+                                            <input type="hidden" name="{{ $inputName }}[{{ $permission->id }}]" value="allow">
+                                        @endif
+                                    </td>
+                                </tr>
+                            @endforeach
+                            </tbody>
+                        </table>
+                    </div>
+                </div>
+            </div>
+        </div>
+    @endforeach
+</div>

+ 1 - 0
resources/views/layouts/menu.blade.php

@@ -40,6 +40,7 @@
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('contract.index', session('gp_contracts')) }}">Договоры</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('contract.index', session('gp_contracts')) }}">Договоры</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('user.index', session('gp_users')) }}">Пользователи</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('user.index', session('gp_users')) }}">Пользователи</a></li>
+                <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.roles.index') }}">Роли и права</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.settings.index') }}">Настройки</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.settings.index') }}">Настройки</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.district.index') }}">Округа</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.district.index') }}">Округа</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.area.index') }}">Районы</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.area.index') }}">Районы</a></li>

+ 52 - 1
resources/views/users/edit.blade.php

@@ -18,6 +18,9 @@
                     <li class="nav-item" role="presentation">
                     <li class="nav-item" role="presentation">
                         <button class="nav-link" id="notifications-tab" data-bs-toggle="tab" data-bs-target="#notifications-pane" type="button" role="tab">Уведомления</button>
                         <button class="nav-link" id="notifications-tab" data-bs-toggle="tab" data-bs-target="#notifications-pane" type="button" role="tab">Уведомления</button>
                     </li>
                     </li>
+                    <li class="nav-item" role="presentation">
+                        <button class="nav-link" id="permissions-tab" data-bs-toggle="tab" data-bs-target="#permissions-pane" type="button" role="tab">Права</button>
+                    </li>
                 </ul>
                 </ul>
 
 
                 <div class="tab-content">
                 <div class="tab-content">
@@ -38,7 +41,12 @@
 
 
                         @include('partials.input', ['name' => 'password', 'type' => 'password', 'title' => 'Пароль'])
                         @include('partials.input', ['name' => 'password', 'type' => 'password', 'title' => 'Пароль'])
 
 
-                        @include('partials.select', ['name' => 'role', 'title' => 'Роль', 'options' => getRoles(), 'value' => $user->role ?? \App\Models\Role::MANAGER])
+                        @include('partials.select', [
+                            'name' => 'role_id',
+                            'title' => 'Роль',
+                            'options' => $roles ?? getRoleIdOptions(),
+                            'value' => old('role_id', $user->role_id ?? null),
+                        ])
 
 
                         @include('partials.input', ['name' => 'color', 'title' => 'Цвет', 'value' => $user->color ?? '#FFFFFF', 'type' => 'color'])
                         @include('partials.input', ['name' => 'color', 'title' => 'Цвет', 'value' => $user->color ?? '#FFFFFF', 'type' => 'color'])
 
 
@@ -61,8 +69,47 @@
                             ],
                             ],
                         ])
                         ])
                     </div>
                     </div>
+
+                    <div class="tab-pane fade" id="permissions-pane" role="tabpanel" aria-labelledby="permissions-tab">
+                        @if(($user?->resolvedRoleSlug()) === \App\Models\Role::ADMIN)
+                            <div class="alert alert-warning py-2">
+                                Администратору всегда доступны все права. Пользовательские deny и снятие прав не применяются.
+                            </div>
+                        @else
+                            <div class="mb-2 text-muted small">
+                                Значение "По роли" оставляет право как в выбранной роли. Allow или deny здесь переопределяют роль только для этого пользователя.
+                            </div>
+                        @endif
+
+                        @include('admin.roles.partials.permissions-table', [
+                            'permissionGroups' => $permissionGroups ?? collect(),
+                            'permissionEffects' => $permissionEffects ?? [],
+                            'inputName' => 'permission_effects',
+                            'adminLocked' => ($user?->resolvedRoleSlug()) === \App\Models\Role::ADMIN,
+                            'inheritLabel' => 'По роли',
+                        ])
+                    </div>
                 </div>
                 </div>
 
 
+                @if($user)
+                    <div class="mt-3 border-top pt-3">
+                        <div class="row">
+                            <label class="col-form-label small col-md-4 text-md-end">Создать роль из прав</label>
+                            <div class="col-md-8">
+                                <div class="input-group input-group-sm mb-2">
+                                    <input type="text" name="name" class="form-control" form="create-role-from-user"
+                                           placeholder="Название новой роли">
+                                    <input type="text" name="slug" class="form-control" form="create-role-from-user"
+                                           placeholder="code">
+                                    <button type="submit" class="btn btn-outline-primary" form="create-role-from-user">
+                                        Создать роль
+                                    </button>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                @endif
+
                 @if($user && !is_null($user->deleted_at))
                 @if($user && !is_null($user->deleted_at))
                     <div class="col-12 text-center">
                     <div class="col-12 text-center">
                         <div class="text-danger">ПОЛЬЗОВАТЕЛЬ УДАЛЁН!!!</div>
                         <div class="text-danger">ПОЛЬЗОВАТЕЛЬ УДАЛЁН!!!</div>
@@ -88,6 +135,10 @@
                 <form action="{{ route('user.impersonate', $user->id) }}" method="post" class="d-none" id="impersonate-user">
                 <form action="{{ route('user.impersonate', $user->id) }}" method="post" class="d-none" id="impersonate-user">
                     @csrf
                     @csrf
                 </form>
                 </form>
+                <form action="{{ route('admin.roles.store-from-user', $user) }}" method="post" class="d-none" id="create-role-from-user">
+                    @csrf
+                    <input type="hidden" name="description" value="Создана из прав пользователя {{ $user->name }}">
+                </form>
             @endif
             @endif
         </div>
         </div>
     </div>
     </div>

+ 1 - 1
resources/views/users/index.blade.php

@@ -40,7 +40,7 @@
                         @include('partials.input', ['name' => 'name', 'title' => 'Имя', 'required' => true])
                         @include('partials.input', ['name' => 'name', 'title' => 'Имя', 'required' => true])
                         @include('partials.input', ['name' => 'phone', 'title' => 'Телефон'])
                         @include('partials.input', ['name' => 'phone', 'title' => 'Телефон'])
                         @include('partials.input', ['name' => 'password', 'type' => 'password', 'title' => 'Пароль', 'required' => true])
                         @include('partials.input', ['name' => 'password', 'type' => 'password', 'title' => 'Пароль', 'required' => true])
-                        @include('partials.select', ['name' => 'role', 'title' => 'Роль', 'options' => getRoles(), 'value' => $user->role ?? \App\Models\Role::MANAGER])
+                        @include('partials.select', ['name' => 'role_id', 'title' => 'Роль', 'options' => getRoleIdOptions(), 'value' => null])
 
 
                         @include('partials.submit', ['name' => 'Добавить', 'back_url' => route('user.index', session('gp_users', []))])
                         @include('partials.submit', ['name' => 'Добавить', 'back_url' => route('user.index', session('gp_users', []))])
 
 

+ 11 - 0
routes/web.php

@@ -3,6 +3,7 @@
 use App\Http\Controllers\Admin\AdminAreaController;
 use App\Http\Controllers\Admin\AdminAreaController;
 use App\Http\Controllers\Admin\AdminDistrictController;
 use App\Http\Controllers\Admin\AdminDistrictController;
 use App\Http\Controllers\Admin\AdminNotificationLogController;
 use App\Http\Controllers\Admin\AdminNotificationLogController;
+use App\Http\Controllers\Admin\RoleController;
 use App\Http\Controllers\Admin\AdminSettingsController;
 use App\Http\Controllers\Admin\AdminSettingsController;
 use App\Http\Controllers\ChatMessageController;
 use App\Http\Controllers\ChatMessageController;
 use App\Http\Controllers\AreaController;
 use App\Http\Controllers\AreaController;
@@ -80,6 +81,16 @@ Route::middleware('auth:web')->group(function () {
             Route::post('undelete/{user}', [UserController::class, 'undelete'])->name('user.undelete');
             Route::post('undelete/{user}', [UserController::class, 'undelete'])->name('user.undelete');
         });
         });
 
 
+        Route::prefix('roles')->name('admin.roles.')->group(function () {
+            Route::get('', [RoleController::class, 'index'])->name('index');
+            Route::get('create', [RoleController::class, 'create'])->name('create');
+            Route::post('', [RoleController::class, 'store'])->name('store');
+            Route::post('from-user/{user}', [RoleController::class, 'storeFromUser'])->name('store-from-user');
+            Route::get('{role}', [RoleController::class, 'edit'])->name('edit');
+            Route::put('{role}', [RoleController::class, 'update'])->name('update');
+            Route::delete('{role}', [RoleController::class, 'destroy'])->name('destroy');
+        });
+
         Route::prefix('import')->group(function (){
         Route::prefix('import')->group(function (){
             Route::get('', [ImportController::class, 'index'])->name('import.index');
             Route::get('', [ImportController::class, 'index'])->name('import.index');
             Route::get('{import}', [ImportController::class, 'show'])->name('import.show');
             Route::get('{import}', [ImportController::class, 'show'])->name('import.show');

+ 134 - 0
tests/Feature/AdminRoleControllerTest.php

@@ -0,0 +1,134 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Permission;
+use App\Models\Role;
+use App\Models\User;
+use Database\Seeders\RbacSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class AdminRoleControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    private User $adminUser;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
+        $this->seed(RbacSeeder::class);
+        $this->adminUser->refresh();
+    }
+
+    public function test_admin_can_open_roles_index(): void
+    {
+        $this->actingAs($this->adminUser)
+            ->get(route('admin.roles.index'))
+            ->assertOk()
+            ->assertSee('Роли и права');
+    }
+
+    public function test_admin_role_update_keeps_all_permissions_allowed(): void
+    {
+        $adminRole = Role::query()->where('slug', Role::ADMIN)->firstOrFail();
+        $permission = Permission::query()->where('slug', 'catalog.delete')->firstOrFail();
+
+        $this->actingAs($this->adminUser)
+            ->put(route('admin.roles.update', $adminRole), [
+                'slug' => Role::ADMIN,
+                'name' => 'Админ',
+                'is_active' => '0',
+                'permission_effects' => [
+                    $permission->id => 'deny',
+                ],
+            ])
+            ->assertRedirect(route('admin.roles.edit', $adminRole));
+
+        $this->assertDatabaseHas('roles', [
+            'id' => $adminRole->id,
+            'is_active' => true,
+        ]);
+        $this->assertDatabaseHas('role_permissions', [
+            'role_id' => $adminRole->id,
+            'permission_id' => $permission->id,
+            'effect' => 'allow',
+        ]);
+    }
+
+    public function test_role_cannot_be_deleted_while_any_user_references_it(): void
+    {
+        $role = Role::query()->where('slug', Role::MANAGER)->firstOrFail();
+        User::factory()->create([
+            'role' => Role::MANAGER,
+            'role_id' => $role->id,
+            'deleted_at' => now(),
+        ]);
+
+        $this->actingAs($this->adminUser)
+            ->delete(route('admin.roles.destroy', $role))
+            ->assertRedirect(route('admin.roles.edit', $role));
+
+        $this->assertDatabaseHas('roles', ['id' => $role->id]);
+    }
+
+    public function test_role_can_be_created_from_user_effective_permissions(): void
+    {
+        $managerRole = Role::query()->where('slug', Role::MANAGER)->firstOrFail();
+        $user = User::factory()->create([
+            'role' => Role::MANAGER,
+            'role_id' => $managerRole->id,
+        ]);
+        $permission = Permission::query()->where('slug', 'catalog.view')->firstOrFail();
+
+        $user->permissions()->syncWithoutDetaching([
+            $permission->id => ['effect' => 'deny'],
+        ]);
+
+        $this->actingAs($this->adminUser)
+            ->post(route('admin.roles.store-from-user', $user), [
+                'name' => 'Тестовая роль',
+                'slug' => 'test_custom_role',
+            ])
+            ->assertRedirect();
+
+        $role = Role::query()->where('slug', 'test_custom_role')->firstOrFail();
+        $this->assertDatabaseHas('role_permissions', [
+            'role_id' => $role->id,
+            'permission_id' => $permission->id,
+            'effect' => 'deny',
+        ]);
+    }
+
+    public function test_user_permission_override_can_deny_role_permission(): void
+    {
+        $managerRole = Role::query()->where('slug', Role::MANAGER)->firstOrFail();
+        $user = User::factory()->create([
+            'role' => Role::MANAGER,
+            'role_id' => $managerRole->id,
+        ]);
+        $permission = Permission::query()->where('slug', 'catalog.view')->firstOrFail();
+
+        $this->actingAs($this->adminUser)
+            ->post(route('user.store'), [
+                'id' => $user->id,
+                'email' => $user->email,
+                'name' => $user->name,
+                'role_id' => $managerRole->id,
+                'permission_effects' => [
+                    $permission->id => 'deny',
+                ],
+            ])
+            ->assertRedirect(route('user.index'));
+
+        $this->assertDatabaseHas('user_permissions', [
+            'user_id' => $user->id,
+            'permission_id' => $permission->id,
+            'effect' => 'deny',
+        ]);
+        $this->assertFalse($user->refresh()->hasPermission('catalog.view'));
+    }
+}