feat(dashboard): add Roles CRUD with permission-based menu filtering

- Roles CRUD: List/Create/Update/Delete actions, controller, requests
- Roles Vue pages: Index, Create, Edit (Options API, DashboardLayout)
- SidebarNav: filter menu items by user permissions via Inertia shared data
- HandleInertiaRequests: share permissions and roles arrays to frontend
- HttpKernel: register dashboard.permission middleware alias
- SidebarNavItem: add shield-check icon mapping
This commit is contained in:
F4ilji
2026-08-03 10:39:15 +05:00
parent d4ef7daeb1
commit 63e456088d
15 changed files with 725 additions and 3 deletions
@@ -0,0 +1,19 @@
<?php
namespace App\Containers\Dashboard\Actions\Roles;
use Spatie\Permission\Models\Role;
class CreateRoleAction
{
public function run(array $data): Role
{
$role = Role::create(['name' => $data['name']]);
if (!empty($data['permissions'])) {
$role->syncPermissions($data['permissions']);
}
return $role;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Containers\Dashboard\Actions\Roles;
use Spatie\Permission\Models\Role;
class DeleteRoleAction
{
public function run(Role $role): void
{
$role->delete();
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Containers\Dashboard\Actions\Roles;
use Spatie\Permission\Models\Role;
class ListRolesAction
{
public function run(): array
{
$roles = Role::with('permissions')->orderBy('name')->get();
return [
'roles' => $roles,
];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Containers\Dashboard\Actions\Roles;
use Spatie\Permission\Models\Role;
class UpdateRoleAction
{
public function run(Role $role, array $data): Role
{
$role->update(['name' => $data['name']]);
$role->syncPermissions($data['permissions'] ?? []);
return $role;
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Containers\Dashboard\UI\WEB\Controllers;
use App\Containers\Dashboard\Actions\Roles\CreateRoleAction;
use App\Containers\Dashboard\Actions\Roles\DeleteRoleAction;
use App\Containers\Dashboard\Actions\Roles\ListRolesAction;
use App\Containers\Dashboard\Actions\Roles\UpdateRoleAction;
use App\Containers\Dashboard\UI\WEB\Requests\StoreRoleRequest;
use App\Containers\Dashboard\UI\WEB\Requests\UpdateRoleRequest;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class RolesController extends Controller
{
public function __construct(
private readonly ListRolesAction $listRolesAction,
private readonly CreateRoleAction $createRoleAction,
private readonly UpdateRoleAction $updateRoleAction,
private readonly DeleteRoleAction $deleteRoleAction,
) {}
public function index(): Response
{
$data = $this->listRolesAction->run();
return Inertia::render('Dashboard/Roles/Index', $data);
}
public function create(): Response
{
$permissions = Permission::orderBy('name')->get(['id', 'name']);
return Inertia::render('Dashboard/Roles/Create', [
'permissions' => $permissions,
]);
}
public function store(StoreRoleRequest $request): RedirectResponse
{
try {
$this->createRoleAction->run($request->validated());
return redirect()->route('dashboard.roles.index')
->with('success', 'Роль успешно создана!');
} catch (\Exception $e) {
return back()
->withInput()
->with('error', 'Ошибка при создании роли: ' . $e->getMessage());
}
}
public function edit(Role $role): Response
{
$role->load('permissions');
$permissions = Permission::orderBy('name')->get(['id', 'name']);
return Inertia::render('Dashboard/Roles/Edit', [
'role' => [
'id' => $role->id,
'name' => $role->name,
'permissions' => $role->permissions->pluck('name')->toArray(),
],
'permissions' => $permissions,
]);
}
public function update(UpdateRoleRequest $request, Role $role): RedirectResponse
{
try {
$this->updateRoleAction->run($role, $request->validated());
return redirect()->route('dashboard.roles.index')
->with('success', 'Роль успешно обновлена!');
} catch (\Exception $e) {
return back()
->withInput()
->with('error', 'Ошибка при обновлении роли: ' . $e->getMessage());
}
}
public function destroy(Role $role): RedirectResponse
{
try {
$this->deleteRoleAction->run($role);
return redirect()->route('dashboard.roles.index')
->with('success', 'Роль успешно удалена!');
} catch (\Exception $e) {
return back()
->with('error', 'Ошибка при удалении роли: ' . $e->getMessage());
}
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Containers\Dashboard\UI\WEB\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreRoleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255', 'unique:roles,name'],
'permissions' => ['nullable', 'array'],
'permissions.*' => ['string', 'exists:permissions,name'],
];
}
public function messages(): array
{
return [
'name.required' => 'Название роли обязательно для заполнения',
'name.unique' => 'Роль с таким названием уже существует',
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Containers\Dashboard\UI\WEB\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateRoleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255', Rule::unique('roles', 'name')->ignore($this->route('role'))],
'permissions' => ['nullable', 'array'],
'permissions.*' => ['string', 'exists:permissions,name'],
];
}
public function messages(): array
{
return [
'name.required' => 'Название роли обязательно для заполнения',
'name.unique' => 'Роль с таким названием уже существует',
];
}
}
@@ -33,6 +33,8 @@ class HandleInertiaRequests extends Middleware
],
'auth' => [
'user' => $request->user() ? $request->user()->only('id', 'name', 'email', 'created_at') : null,
'permissions' => $request->user()?->getAllPermissions()->pluck('name')->toArray() ?? [],
'roles' => $request->user()?->getRoleNames()->toArray() ?? [],
],
'ziggy' => function() {
return array_merge((new Ziggy())->toArray(), [
+1
View File
@@ -91,5 +91,6 @@ class HttpKernel extends LaravelHttpKernel
'limit.post' => LimitPost::class,
'form.time.period' => FormTimePeriodMiddleware::class,
'vikon.refresh' => \App\Http\Middleware\VikonTokenRefresh::class,
'dashboard.permission' => \App\Ship\Middleware\CheckDashboardPermission::class,
];
}
@@ -34,6 +34,8 @@ class HandleInertiaRequests extends Middleware
],
'auth' => [
'user' => $request->user() ? $request->user()->only('id', 'name', 'email', 'created_at') : null,
'permissions' => $request->user()?->getAllPermissions()->pluck('name')->toArray() ?? [],
'roles' => $request->user()?->getRoleNames()->toArray() ?? [],
],
'ziggy' => fn () => [
...(new Ziggy)->toArray(),
@@ -2,7 +2,7 @@
<nav class="flex-1 overflow-y-auto px-3 py-4">
<div class="space-y-1">
<SidebarNavItem
v-for="item in menuItems"
v-for="item in visibleMenuItems"
:key="item.key"
:item="item"
:mobile="mobile"
@@ -44,6 +44,7 @@
<script setup>
import { computed } from 'vue';
import { usePage } from '@inertiajs/vue3';
import { menuItems } from './menuConfig';
import { quickActions } from './quickActionsConfig';
import SidebarNavItem from './SidebarNavItem.vue';
@@ -55,15 +56,24 @@ defineProps({
defineEmits(['child-click']);
const page = usePage();
const userPermissions = computed(() => page.props.auth?.permissions ?? []);
const visibleMenuItems = computed(() => {
return menuItems.filter(item => {
if (!item.permission) return true;
return userPermissions.value.includes(item.permission);
});
});
const iconMap = {
cog: DashboardIcon,
};
// Открываем только аккордеон активной страницы
const expandedKey = computed(() => {
const currentRoute = route().current();
for (const item of menuItems) {
for (const item of visibleMenuItems.value) {
if (item.activePrefixes && item.activePrefixes.some(prefix => currentRoute.startsWith(prefix))) {
return item.key;
}
@@ -76,6 +76,7 @@ const iconMap = {
'arrow-path': 'arrow-path',
'academic-cap': 'academic-cap',
'clipboard-document-check': 'clipboard-document-check',
'shield-check': 'shield-check',
key: 'key',
};
@@ -0,0 +1,178 @@
<template>
<DashboardLayout>
<template #header-icon>
<DashboardIcon name="plus" size="5" class="text-primary" />
</template>
<template #header-title>Создание роли</template>
<template #header-subtitle>Добавление новой роли в систему</template>
<template #header-actions>
<a
:href="route('dashboard.roles.index')"
class="inline-flex items-center gap-2 px-4 py-2 bg-surface text-foreground text-sm font-medium rounded-lg border border-layer-line hover:bg-muted-hover transition-all duration-200"
>
<DashboardIcon name="arrow-left" size="4" />
Назад к списку
</a>
</template>
<FlashMessages />
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
<form @submit.prevent="submit">
<div class="p-6 space-y-6">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-foreground mb-1">
Название роли <span class="text-danger">*</span>
</label>
<input
v-model="form.name"
type="text"
required
maxlength="255"
placeholder="Например: editor, viewer, admin"
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<p v-if="errors.name" class="mt-1 text-sm text-danger">{{ errors.name }}</p>
</div>
<!-- Permissions -->
<div>
<h3 class="text-lg font-medium text-foreground mb-3">Пермишены</h3>
<p class="text-sm text-muted-foreground-1 mb-4">Выберите пермишены для этой роли</p>
<div v-for="(group, prefix) in groupedPermissions" :key="prefix" class="mb-4">
<div
class="flex items-center justify-between px-4 py-2.5 bg-surface/50 border border-layer-line rounded-t-lg cursor-pointer hover:bg-muted-hover transition-colors"
@click="toggleGroup(prefix)"
>
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="isGroupSelected(prefix)"
@change.stop="toggleGroup(prefix)"
class="w-4 h-4 rounded border-layer-line text-primary focus:ring-primary focus:ring-offset-0"
/>
<span class="text-sm font-medium text-foreground">{{ prefixLabels[prefix] || prefix }}</span>
<span class="text-xs text-muted-foreground-1">({{ group.length }})</span>
</div>
</div>
<div class="border border-t-0 border-layer-line rounded-b-lg px-4 py-3 bg-white">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
<label
v-for="perm in group"
:key="perm.name"
class="flex items-center gap-2 cursor-pointer hover:bg-muted-hover/50 px-2 py-1 rounded transition-colors"
>
<input
type="checkbox"
:value="perm.name"
v-model="form.permissions"
class="w-4 h-4 rounded border-layer-line text-primary focus:ring-primary focus:ring-offset-0"
/>
<span class="text-sm text-foreground">{{ perm.name }}</span>
</label>
</div>
</div>
</div>
<p v-if="errors.permissions" class="mt-1 text-sm text-danger">{{ errors.permissions }}</p>
</div>
</div>
<!-- Actions -->
<div class="px-6 py-4 bg-surface/50 border-t border-layer-line flex items-center justify-end gap-3">
<a
:href="route('dashboard.roles.index')"
class="px-4 py-2 text-sm font-medium text-foreground bg-surface border border-layer-line rounded-lg hover:bg-muted-hover transition-all"
>
Отмена
</a>
<button
type="submit"
:disabled="processing"
class="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ processing ? 'Создание...' : 'Создать роль' }}
</button>
</div>
</form>
</div>
</DashboardLayout>
</template>
<script setup>
import { reactive, computed, onMounted } from 'vue';
import { router } from '@inertiajs/vue3';
import DashboardLayout from '../Components/DashboardLayout.vue';
import DashboardIcon from '../Components/DashboardIcon.vue';
import FlashMessages from '../Components/shared/FlashMessages.vue';
const props = defineProps({
permissions: { type: Array, required: true },
errors: { type: Object, default: () => ({}) },
});
const form = reactive({
name: '',
permissions: [],
});
const processing = false;
const prefixLabels = {
view_any: 'Просмотр',
create: 'Создание',
update: 'Редактирование',
delete: 'Удаление',
restore: 'Восстановление',
force_delete: 'Принудительное удаление',
replicate: 'Дублирование',
reorder: 'Переупорядочивание',
};
const groupedPermissions = computed(() => {
const groups = {};
for (const perm of props.permissions) {
const parts = perm.name.split('_');
let prefix;
if (parts[0] === 'view' && parts[1] === 'any') {
prefix = 'view_any';
} else if (parts[0] === 'force' && parts[1] === 'delete') {
prefix = 'force_delete';
} else {
prefix = parts[0];
}
if (!groups[prefix]) groups[prefix] = [];
groups[prefix].push(perm);
}
return groups;
});
const isGroupSelected = (prefix) => {
const group = groupedPermissions.value[prefix] || [];
return group.length > 0 && group.every(p => form.permissions.includes(p.name));
};
const toggleGroup = (prefix) => {
const group = groupedPermissions.value[prefix] || [];
if (isGroupSelected(prefix)) {
const groupNames = group.map(p => p.name);
form.permissions = form.permissions.filter(name => !groupNames.includes(name));
} else {
const groupNames = group.map(p => p.name);
form.permissions = [...new Set([...form.permissions, ...groupNames])];
}
};
const submit = () => {
router.post(route('dashboard.roles.store'), {
name: form.name,
permissions: form.permissions,
});
};
onMounted(() => {
document.title = 'Создание роли — Dashboard';
});
</script>
+179
View File
@@ -0,0 +1,179 @@
<template>
<DashboardLayout>
<template #header-icon>
<DashboardIcon name="pencil-square" size="5" class="text-primary" />
</template>
<template #header-title>Редактирование роли</template>
<template #header-subtitle>Изменение параметров роли «{{ role.name }}»</template>
<template #header-actions>
<a
:href="route('dashboard.roles.index')"
class="inline-flex items-center gap-2 px-4 py-2 bg-surface text-foreground text-sm font-medium rounded-lg border border-layer-line hover:bg-muted-hover transition-all duration-200"
>
<DashboardIcon name="arrow-left" size="4" />
Назад к списку
</a>
</template>
<FlashMessages />
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
<form @submit.prevent="submit">
<div class="p-6 space-y-6">
<!-- Name -->
<div>
<label class="block text-sm font-medium text-foreground mb-1">
Название роли <span class="text-danger">*</span>
</label>
<input
v-model="form.name"
type="text"
required
maxlength="255"
placeholder="Например: editor, viewer, admin"
class="w-full px-3 py-2 border border-layer-line rounded-lg bg-white text-foreground placeholder-muted-foreground-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<p v-if="errors.name" class="mt-1 text-sm text-danger">{{ errors.name }}</p>
</div>
<!-- Permissions -->
<div>
<h3 class="text-lg font-medium text-foreground mb-3">Пермишены</h3>
<p class="text-sm text-muted-foreground-1 mb-4">Выберите пермишены для этой роли</p>
<div v-for="(group, prefix) in groupedPermissions" :key="prefix" class="mb-4">
<div
class="flex items-center justify-between px-4 py-2.5 bg-surface/50 border border-layer-line rounded-t-lg cursor-pointer hover:bg-muted-hover transition-colors"
@click="toggleGroup(prefix)"
>
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="isGroupSelected(prefix)"
@change.stop="toggleGroup(prefix)"
class="w-4 h-4 rounded border-layer-line text-primary focus:ring-primary focus:ring-offset-0"
/>
<span class="text-sm font-medium text-foreground">{{ prefixLabels[prefix] || prefix }}</span>
<span class="text-xs text-muted-foreground-1">({{ group.length }})</span>
</div>
</div>
<div class="border border-t-0 border-layer-line rounded-b-lg px-4 py-3 bg-white">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
<label
v-for="perm in group"
:key="perm.name"
class="flex items-center gap-2 cursor-pointer hover:bg-muted-hover/50 px-2 py-1 rounded transition-colors"
>
<input
type="checkbox"
:value="perm.name"
v-model="form.permissions"
class="w-4 h-4 rounded border-layer-line text-primary focus:ring-primary focus:ring-offset-0"
/>
<span class="text-sm text-foreground">{{ perm.name }}</span>
</label>
</div>
</div>
</div>
<p v-if="errors.permissions" class="mt-1 text-sm text-danger">{{ errors.permissions }}</p>
</div>
</div>
<!-- Actions -->
<div class="px-6 py-4 bg-surface/50 border-t border-layer-line flex items-center justify-end gap-3">
<a
:href="route('dashboard.roles.index')"
class="px-4 py-2 text-sm font-medium text-foreground bg-surface border border-layer-line rounded-lg hover:bg-muted-hover transition-all"
>
Отмена
</a>
<button
type="submit"
:disabled="processing"
class="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ processing ? 'Сохранение...' : 'Сохранить изменения' }}
</button>
</div>
</form>
</div>
</DashboardLayout>
</template>
<script setup>
import { reactive, computed, onMounted } from 'vue';
import { router } from '@inertiajs/vue3';
import DashboardLayout from '../Components/DashboardLayout.vue';
import DashboardIcon from '../Components/DashboardIcon.vue';
import FlashMessages from '../Components/shared/FlashMessages.vue';
const props = defineProps({
role: { type: Object, required: true },
permissions: { type: Array, required: true },
errors: { type: Object, default: () => ({}) },
});
const form = reactive({
name: props.role.name,
permissions: [...props.role.permissions],
});
const processing = false;
const prefixLabels = {
view_any: 'Просмотр',
create: 'Создание',
update: 'Редактирование',
delete: 'Удаление',
restore: 'Восстановление',
force_delete: 'Принудительное удаление',
replicate: 'Дублирование',
reorder: 'Переупорядочивание',
};
const groupedPermissions = computed(() => {
const groups = {};
for (const perm of props.permissions) {
const parts = perm.name.split('_');
let prefix;
if (parts[0] === 'view' && parts[1] === 'any') {
prefix = 'view_any';
} else if (parts[0] === 'force' && parts[1] === 'delete') {
prefix = 'force_delete';
} else {
prefix = parts[0];
}
if (!groups[prefix]) groups[prefix] = [];
groups[prefix].push(perm);
}
return groups;
});
const isGroupSelected = (prefix) => {
const group = groupedPermissions.value[prefix] || [];
return group.length > 0 && group.every(p => form.permissions.includes(p.name));
};
const toggleGroup = (prefix) => {
const group = groupedPermissions.value[prefix] || [];
if (isGroupSelected(prefix)) {
const groupNames = group.map(p => p.name);
form.permissions = form.permissions.filter(name => !groupNames.includes(name));
} else {
const groupNames = group.map(p => p.name);
form.permissions = [...new Set([...form.permissions, ...groupNames])];
}
};
const submit = () => {
router.put(route('dashboard.roles.update', props.role.id), {
name: form.name,
permissions: form.permissions,
});
};
onMounted(() => {
document.title = 'Редактирование роли — Dashboard';
});
</script>
@@ -0,0 +1,124 @@
<template>
<DashboardLayout>
<template #header-icon>
<DashboardIcon name="shield-check" size="5" class="text-primary" />
</template>
<template #header-title>Роли</template>
<template #header-subtitle>Управление ролями и пермишенами</template>
<template #header-actions>
<a
:href="route('dashboard.roles.create')"
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all duration-200 shadow-sm hover:shadow-md"
>
<DashboardIcon name="plus" size="4" />
Создать роль
</a>
</template>
<template #breadcrumbs>
<Breadcrumbs :crumbs="[{ label: 'Роли', href: route('dashboard.roles.index') }]" />
</template>
<FlashMessages />
<!-- Roles Table -->
<div class="bg-layer border border-layer-line rounded-lg shadow-xs overflow-hidden">
<div class="px-6 py-4 border-b border-line-2 bg-surface/50">
<div class="flex items-center justify-between">
<span class="text-sm text-foreground">
Всего: <span class="font-medium">{{ roles.length }}</span>
</span>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-line-2">
<thead class="bg-surface/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
Название
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
Пермишены
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
Действия
</th>
</tr>
</thead>
<tbody class="divide-y divide-line-2">
<tr
v-for="role in roles"
:key="role.id"
class="group hover:bg-muted-hover/50 transition-all duration-200"
>
<td class="px-6 py-4">
<div class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
{{ role.name }}
</div>
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
<span
v-if="role.permissions && role.permissions.length > 0"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary"
>
{{ role.permissions.length }} пермишенов
</span>
<span v-else class="text-xs text-muted-foreground-1">Нет пермишенов</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<a
:href="route('dashboard.roles.edit', role.id)"
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
title="Редактировать"
>
<DashboardIcon name="pencil-square" size="4" />
</a>
<button
@click.prevent="CONFIRM_AND_DELETE(role, 'dashboard.roles.destroy', {
message: 'Удалить роль «' + role.name + '»?'
})"
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
title="Удалить"
>
<DashboardIcon name="trash" size="4" />
</button>
</div>
</td>
</tr>
<EmptyState
v-if="roles.length === 0"
:columns="3"
title="Роли не найдены"
description="Создайте первую роль для управления доступом"
:action-url="route('dashboard.roles.create')"
action-text="Создать роль"
icon-path="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</tbody>
</table>
</div>
</div>
</DashboardLayout>
</template>
<script setup>
import { onMounted } from 'vue';
import DashboardLayout from '../Components/DashboardLayout.vue';
import DashboardIcon from '../Components/DashboardIcon.vue';
import Breadcrumbs from '../Components/shared/Breadcrumbs.vue';
import FlashMessages from '../Components/shared/FlashMessages.vue';
import EmptyState from '../Components/shared/EmptyState.vue';
defineProps({
roles: { type: Array, required: true },
});
onMounted(() => {
document.title = 'Роли — Dashboard';
});
</script>