From e9237f857e464b8ec7e1e9b606932cd3091eb692 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 31 Jul 2026 23:15:55 +0700 Subject: [PATCH] feat: add role management functionality with create, edit, and delete capabilities - Implemented RoleEdit component for editing roles with permissions. - Created RoleIndex component for listing roles with delete confirmation. - Added routes for role management in web.php with appropriate permissions. - Developed RoleTest to cover authentication, authorization, and data integrity for role management. --- app/Http/Controllers/Admin/RoleController.php | 67 ++ app/Http/Requests/Admin/RoleRequest.php | 38 + app/Services/Admin/Settings/RoleService.php | 54 ++ bootstrap/app.php | 17 +- config/permission.php | 219 +++++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/RolePermissionSeeder.php | 117 +++ database/seeders/UserSeeder.php | 4 + resources/js/components/app-sidebar.tsx | 3 + resources/js/pages/admin/roles/columns.tsx | 124 +++ resources/js/pages/admin/roles/create.tsx | 164 ++++ resources/js/pages/admin/roles/edit.tsx | 178 ++++ resources/js/pages/admin/roles/index.tsx | 89 ++ routes/web.php | 82 +- tests/Feature/Admin/Settings/RoleTest.php | 768 ++++++++++++++++++ 15 files changed, 1885 insertions(+), 40 deletions(-) create mode 100644 app/Http/Controllers/Admin/RoleController.php create mode 100644 app/Http/Requests/Admin/RoleRequest.php create mode 100644 app/Services/Admin/Settings/RoleService.php create mode 100644 config/permission.php create mode 100644 database/seeders/RolePermissionSeeder.php create mode 100644 resources/js/pages/admin/roles/columns.tsx create mode 100644 resources/js/pages/admin/roles/create.tsx create mode 100644 resources/js/pages/admin/roles/edit.tsx create mode 100644 resources/js/pages/admin/roles/index.tsx create mode 100644 tests/Feature/Admin/Settings/RoleTest.php diff --git a/app/Http/Controllers/Admin/RoleController.php b/app/Http/Controllers/Admin/RoleController.php new file mode 100644 index 0000000..da23f55 --- /dev/null +++ b/app/Http/Controllers/Admin/RoleController.php @@ -0,0 +1,67 @@ + $this->service->getAll(), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/roles/create', [ + 'permissions' => $this->service->getPermissionsByModule(), + ]); + } + + public function store(RoleRequest $request): RedirectResponse + { + return $this->handleAction( + fn() => $this->service->create($request->validated()), + 'Role berhasil ditambahkan.', + 'admin.settings.roles.index' + ); + } + + public function edit(Role $role): Response + { + return Inertia::render('admin/roles/edit', [ + 'role' => $this->service->getById($role->id), + 'permissions' => $this->service->getPermissionsByModule(), + ]); + } + + public function update(RoleRequest $request, Role $role): RedirectResponse + { + return $this->handleAction( + fn() => $this->service->update($role, $request->validated()), + 'Role berhasil diperbarui.', + 'admin.settings.roles.index' + ); + } + + public function destroy(Role $role): RedirectResponse + { + $this->service->delete($role); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Role berhasil dihapus.']); + + return to_route('admin.settings.roles.index'); + } +} diff --git a/app/Http/Requests/Admin/RoleRequest.php b/app/Http/Requests/Admin/RoleRequest.php new file mode 100644 index 0000000..ed1cfda --- /dev/null +++ b/app/Http/Requests/Admin/RoleRequest.php @@ -0,0 +1,38 @@ +route('role')?->id; + + return [ + 'name' => [ + 'required', + 'string', + 'max:100', + Rule::unique('roles', 'name')->ignore($roleId, 'id'), + ], + 'permissions' => ['present', 'array'], + 'permissions.*' => ['string', 'exists:permissions,name'], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'Nama Role', + 'permissions' => 'Permission', + ]; + } +} diff --git a/app/Services/Admin/Settings/RoleService.php b/app/Services/Admin/Settings/RoleService.php new file mode 100644 index 0000000..57bf0b9 --- /dev/null +++ b/app/Services/Admin/Settings/RoleService.php @@ -0,0 +1,54 @@ +get(); + } + + public function getById(int $id): Role + { + return Role::with('permissions')->findOrFail($id); + } + + public function create(array $data): Role + { + return DB::transaction(function () use ($data) { + $role = Role::create(['name' => $data['name']]); + $role->syncPermissions($data['permissions']); + + return $role; + }); + } + + public function update(Role $role, array $data): Role + { + DB::transaction(function () use ($role, $data) { + $role->update(['name' => $data['name']]); + $role->syncPermissions($data['permissions']); + }); + + return $role->fresh('permissions'); + } + + public function delete(Role $role): bool + { + return $role->delete(); + } + + public function getPermissionsByModule(): array + { + return Permission::all() + ->groupBy(fn ($p) => explode('.', $p->name)[0]) + ->map(fn ($group) => $group->pluck('name')->map(fn ($name) => explode('.', $name, 2)[1])->values()->toArray()) + ->toArray(); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index e95b56b..9836e43 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,12 +7,15 @@ use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; use Illuminate\Http\Request; +use Spatie\Permission\Middleware\RoleMiddleware; +use Spatie\Permission\Middleware\PermissionMiddleware; +use Spatie\Permission\Middleware\RoleOrPermissionMiddleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( - web: __DIR__.'/../routes/web.php', - api: __DIR__.'/../routes/api.php', - commands: __DIR__.'/../routes/console.php', + web: __DIR__ . '/../routes/web.php', + api: __DIR__ . '/../routes/api.php', + commands: __DIR__ . '/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { @@ -23,9 +26,15 @@ HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, ]); + + $middleware->alias([ + 'role' => RoleMiddleware::class, + 'permission' => PermissionMiddleware::class, + 'role_or_permission' => RoleOrPermissionMiddleware::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( - fn (Request $request) => $request->is('api/*') || $request->expectsJson(), + fn(Request $request) => $request->is('api/*') || $request->expectsJson(), ); })->create(); diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000..8f1f452 --- /dev/null +++ b/config/permission.php @@ -0,0 +1,219 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Spatie\Permission\Contracts\Permission` contract. + */ + + 'permission' => Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Spatie\Permission\Contracts\Role` contract. + */ + + 'role' => Role::class, + + /* + * When using the "Teams" feature from this package, we need to know which + * Eloquent model should be used to retrieve your teams. Of course, it + * is often just the "Team" model but you may use whatever you like. + */ + 'team' => null, + + /* + * When using the "HasModels" trait and passing raw IDs to syncModels, + * attachModels, or detachModels, this model class will be used to + * resolve those IDs. If null, defaults to the guard's model. + */ + 'default_model' => null, + ], + + 'table_names' => [ + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_permissions' => 'model_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_roles' => 'model_has_roles', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `model_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `model_uuid`. + */ + + 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', + ], + + /* + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Events will fire when a role or permission is assigned/unassigned: + * \Spatie\Permission\Events\RoleAttachedEvent + * \Spatie\Permission\Events\RoleDetachedEvent + * \Spatie\Permission\Events\PermissionAttachedEvent + * \Spatie\Permission\Events\PermissionDetachedEvent + * + * To enable, set to true, and then create listeners to watch these events. + */ + 'events_enabled' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => false, + + /* + * The class to use to resolve the permissions team id + */ + 'team_resolver' => DefaultTeamResolver::class, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_permission_in_exception' => false, + + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + + 'cache' => [ + + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_time' => DateInterval::createFromDateString('24 hours'), + + /* + * The cache key used to store all permissions. + */ + + 'key' => 'spatie.permission.cache', + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => 'default', + ], +]; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index f9900fa..92a102a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,6 +15,7 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call([ + RolePermissionSeeder::class, UserSeeder::class, CategorySeeder::class, SupplierSeeder::class, diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php new file mode 100644 index 0000000..0a47f8b --- /dev/null +++ b/database/seeders/RolePermissionSeeder.php @@ -0,0 +1,117 @@ +forgetCachedPermissions(); + + $permissions = [ + 'user' => ['view', 'create', 'update', 'delete', 'toggle-active', 'reset-password'], + 'category' => ['view', 'create', 'update', 'delete'], + 'supplier' => ['view', 'create', 'update', 'delete'], + 'customer' => ['view', 'create', 'update', 'delete'], + 'cash-account' => ['view', 'deposit', 'withdrawal', 'update', 'delete'], + 'expense' => ['view', 'create', 'update', 'delete'], + 'employee-advance' => ['view', 'create', 'update', 'delete', 'approve', 'pay'], + 'payroll-period' => ['view', 'show', 'current', 'close', 'reopen'], + 'payroll' => ['pay', 'cancel'], + 'payroll-adjustment' => ['create', 'delete'], + 'leave-request' => ['view', 'create', 'update', 'delete', 'approve', 'reject'], + 'attendance' => ['view', 'check-in', 'check-out', 'by-date'], + 'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-marketplace', 'update-hr'], + ]; + + foreach ($permissions as $module => $actions) { + foreach ($actions as $action) { + Permission::firstOrCreate(['name' => "{$module}.{$action}"]); + } + } + + $allPermissions = Permission::all()->pluck('name')->toArray(); + + $rolePermissions = [ + 'Developer' => $allPermissions, + + 'Owner' => array_filter($allPermissions, fn($p) => !str_starts_with($p, 'settings.update-')), + + 'Direktur' => array_filter($allPermissions, function ($p) { + return str_ends_with($p, '.view') + || str_starts_with($p, 'payroll-period.') + || $p === 'payroll.pay' + || $p === 'leave-request.approve' + || $p === 'leave-request.reject' + || $p === 'attendance.by-date'; + }), + + 'Admin Bahan Baku' => array_filter($allPermissions, function ($p) { + return str_starts_with($p, 'supplier.') + || $p === 'category.view' + || $p === 'customer.view' + || $p === 'cash-account.view'; + }), + + 'Admin Toko' => array_filter($allPermissions, function ($p) { + return str_starts_with($p, 'category.') + || str_starts_with($p, 'customer.') + || str_starts_with($p, 'expense.') + || $p === 'cash-account.view' + || $p === 'leave-request.view' + || $p === 'leave-request.create' + || str_starts_with($p, 'attendance.'); + }), + + 'Marketing Offline' => array_filter($allPermissions, function ($p) { + return str_starts_with($p, 'customer.') + || $p === 'category.view' + || $p === 'supplier.view' + || $p === 'leave-request.view' + || $p === 'leave-request.create' + || $p === 'attendance.check-in' + || $p === 'attendance.check-out'; + }), + + 'Marketing Online' => array_filter($allPermissions, function ($p) { + return str_starts_with($p, 'customer.') + || $p === 'category.view' + || $p === 'supplier.view' + || $p === 'leave-request.view' + || $p === 'leave-request.create' + || $p === 'attendance.check-in' + || $p === 'attendance.check-out'; + }), + + 'Kasir' => array_filter($allPermissions, function ($p) { + return str_starts_with($p, 'cash-account.') + || $p === 'expense.view' + || $p === 'leave-request.view' + || $p === 'leave-request.create' + || $p === 'attendance.check-in' + || $p === 'attendance.check-out'; + }), + + 'Stok Opname' => array_filter($allPermissions, function ($p) { + return str_starts_with($p, 'supplier.') + || $p === 'category.view' + || $p === 'cash-account.view' + || $p === 'leave-request.view' + || $p === 'leave-request.create' + || $p === 'attendance.check-in' + || $p === 'attendance.check-out'; + }), + + 'Non Operator' => array_filter($allPermissions, fn($p) => str_ends_with($p, '.view')), + ]; + + foreach ($rolePermissions as $roleName => $perms) { + $role = Role::firstOrCreate(['name' => $roleName]); + $role->syncPermissions($perms); + } + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index bb25cbf..3970249 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -26,6 +26,8 @@ public function run(): void 'gender' => Gender::MALE->value, ]); + $developer->assignRole('Developer'); + $owner = User::factory()->create([ 'email' => 'owner@gmail.com', 'username' => 'owner', @@ -37,6 +39,8 @@ public function run(): void 'full_name' => 'Owner', 'gender' => Gender::MALE->value, ]); + + $owner->assignRole('Owner'); }); } } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index c7f5df2..e4a1088 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -21,6 +21,7 @@ import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests'; import { index as categoriesIndex } from '@/routes/admin/master/categories'; import { index as customersIndex } from '@/routes/admin/master/customers'; import { index as suppliersIndex } from '@/routes/admin/master/suppliers'; +import { index as rolesIndex } from '@/routes/admin/settings/roles'; import { Link } from '@inertiajs/react'; import type { LucideIcon } from 'lucide-react'; import { @@ -38,6 +39,7 @@ import { RefreshCw, Scissors, Settings, + Shield, ShoppingCart, Tags, Truck, @@ -90,6 +92,7 @@ const hrItems: NavMenuItem[] = [ const sistemItems: NavMenuItem[] = [ { title: 'Pengaturan', href: '/admin/settings', icon: Settings }, + { title: 'Role & Permission', href: rolesIndex.url(), icon: Shield }, { title: 'Log Aktivitas', href: '#', icon: Activity }, ]; diff --git a/resources/js/pages/admin/roles/columns.tsx b/resources/js/pages/admin/roles/columns.tsx new file mode 100644 index 0000000..5782c1f --- /dev/null +++ b/resources/js/pages/admin/roles/columns.tsx @@ -0,0 +1,124 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { ArrowUpDown, Pencil, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +export type Role = { + id: number; + name: string; + permissions_count: number; +}; + +type CreateColumnsParams = { + handleEdit: (role: Role) => void; + handleDeleteClick: (role: Role) => void; +}; + +export function createRoleColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleEdit, handleDeleteClick } = params; + + return [ + { + id: 'no', + header: () => No, + cell: ({ row }) => ( + + {row.index + 1} + + ), + meta: { + className: 'w-[50px] text-center', + headerClassName: 'w-[50px] text-center', + }, + }, + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.getValue('name') as string} + + ), + }, + { + accessorKey: 'permissions_count', + header: () => Jumlah Permission, + meta: { + className: 'w-[180px] text-center', + headerClassName: 'w-[180px] text-center', + }, + cell: ({ row }) => ( + + {row.getValue('permissions_count') as number} permission + + ), + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => { + const role = row.original; + + return ( + +
+ + + + + + Edit + + + + + + + + + Hapus + + +
+
+ ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/roles/create.tsx b/resources/js/pages/admin/roles/create.tsx new file mode 100644 index 0000000..9caef81 --- /dev/null +++ b/resources/js/pages/admin/roles/create.tsx @@ -0,0 +1,164 @@ +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { index as rolesIndex, store } from '@/routes/admin/settings/roles'; +import { Form, Head } from '@inertiajs/react'; +import { ArrowLeft } from 'lucide-react'; + +type PermissionsByModule = Record; + +type Props = { + permissions: PermissionsByModule; +}; + +const moduleLabels: Record = { + 'user': 'Pengguna', + 'category': 'Kategori', + 'supplier': 'Supplier', + 'customer': 'Customer', + 'cash-account': 'Kas Toko', + 'expense': 'Pengeluaran', + 'employee-advance': 'Kasbon', + 'payroll-period': 'Periode Gaji', + 'payroll': 'Gaji', + 'payroll-adjustment': 'Adjustment Gaji', + 'leave-request': 'Cuti', + 'attendance': 'Absensi', + 'settings': 'Pengaturan', +}; + +const actionLabels: Record = { + 'view': 'Lihat', + 'create': 'Tambah', + 'update': 'Edit', + 'delete': 'Hapus', + 'toggle-active': 'Aktif/Nonaktif', + 'reset-password': 'Reset Kata Sandi', + 'deposit': 'Setor', + 'withdrawal': 'Tarik', + 'approve': 'Setujui', + 'pay': 'Bayar', + 'reject': 'Tolak', + 'current': 'Periode Saat Ini', + 'close': 'Tutup', + 'reopen': 'Buka Kembali', + 'cancel': 'Batalkan', + 'check-in': 'Check In', + 'check-out': 'Check Out', + 'by-date': 'Lihat Per Tanggal', + 'show': 'Detail', + 'update-system': 'Update Sistem', + 'update-homepage': 'Update Homepage', + 'update-social-media': 'Update Media Sosial', + 'update-marketplace': 'Update Marketplace', + 'update-hr': 'Update HR', +}; + +export default function RoleCreate({ permissions }: Props) { + return ( + <> + + +
+
+
+

+ Tambah Role +

+
+ +
+ +
+ {({ errors, processing }) => ( + <> +
+ + + Informasi Role + + +
+ + + +
+
+
+ + + + Permission + + + {Object.entries(permissions).map(([module, actions]) => ( +
+ +
+ {actions.map((action) => ( + + ))} +
+
+ ))} + +
+
+
+ +
+ +
+ + )} +
+
+ + ); +} + +RoleCreate.layout = { + breadcrumbs: [ + { + title: 'Pengaturan', + href: '/admin/settings', + }, + { + title: 'Role & Permission', + href: rolesIndex.url(), + }, + { + title: 'Tambah', + href: '#', + }, + ], +}; diff --git a/resources/js/pages/admin/roles/edit.tsx b/resources/js/pages/admin/roles/edit.tsx new file mode 100644 index 0000000..692d6d9 --- /dev/null +++ b/resources/js/pages/admin/roles/edit.tsx @@ -0,0 +1,178 @@ +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { index as rolesIndex, update } from '@/routes/admin/settings/roles'; +import { Form, Head } from '@inertiajs/react'; +import { ArrowLeft } from 'lucide-react'; + +type PermissionsByModule = Record; + +type RoleData = { + id: number; + name: string; + permissions: { + id: number; + name: string; + }[]; +}; + +type Props = { + role: RoleData; + permissions: PermissionsByModule; +}; + +const moduleLabels: Record = { + 'user': 'Pengguna', + 'category': 'Kategori', + 'supplier': 'Supplier', + 'customer': 'Customer', + 'cash-account': 'Kas Toko', + 'expense': 'Pengeluaran', + 'employee-advance': 'Kasbon', + 'payroll-period': 'Periode Gaji', + 'payroll': 'Gaji', + 'payroll-adjustment': 'Adjustment Gaji', + 'leave-request': 'Cuti', + 'attendance': 'Absensi', + 'settings': 'Pengaturan', +}; + +const actionLabels: Record = { + 'view': 'Lihat', + 'create': 'Tambah', + 'update': 'Edit', + 'delete': 'Hapus', + 'toggle-active': 'Aktif/Nonaktif', + 'reset-password': 'Reset Kata Sandi', + 'deposit': 'Setor', + 'withdrawal': 'Tarik', + 'approve': 'Setujui', + 'pay': 'Bayar', + 'reject': 'Tolak', + 'current': 'Periode Saat Ini', + 'close': 'Tutup', + 'reopen': 'Buka Kembali', + 'cancel': 'Batalkan', + 'check-in': 'Check In', + 'check-out': 'Check Out', + 'by-date': 'Lihat Per Tanggal', + 'show': 'Detail', + 'update-system': 'Update Sistem', + 'update-homepage': 'Update Homepage', + 'update-social-media': 'Update Media Sosial', + 'update-marketplace': 'Update Marketplace', + 'update-hr': 'Update HR', +}; + +export default function RoleEdit({ role, permissions }: Props) { + const assignedPermissions = role.permissions.map((p) => p.name); + + return ( + <> + + +
+
+
+

+ Edit Role +

+
+ +
+ +
+ {({ errors, processing }) => ( + <> +
+ + + Informasi Role + + +
+ + + +
+
+
+ + + + Permission + + + {Object.entries(permissions).map(([module, actions]) => ( +
+ +
+ {actions.map((action) => ( + + ))} +
+
+ ))} + +
+
+
+ +
+ +
+ + )} +
+
+ + ); +} + +RoleEdit.layout = { + breadcrumbs: [ + { + title: 'Pengaturan', + href: '/admin/settings', + }, + { + title: 'Role & Permission', + href: rolesIndex.url(), + }, + { + title: 'Edit', + href: '#', + }, + ], +}; diff --git a/resources/js/pages/admin/roles/index.tsx b/resources/js/pages/admin/roles/index.tsx new file mode 100644 index 0000000..e86e580 --- /dev/null +++ b/resources/js/pages/admin/roles/index.tsx @@ -0,0 +1,89 @@ +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { DataTable } from '@/components/data-table'; +import { Button } from '@/components/ui/button'; +import { index as rolesIndex, create as roleCreate, edit as roleEdit, destroy as roleDestroy } from '@/routes/admin/settings/roles'; +import { Head, router } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useState } from 'react'; +import { createRoleColumns, type Role } from './columns'; + +type Props = { + roles: Role[]; +}; + +export default function RoleIndex({ roles }: Props) { + const [deleting, setDeleting] = useState(null); + + function handleDelete() { + if (!deleting) { + return; + } + + router.delete(roleDestroy.url(deleting.id), { + onSuccess: () => setDeleting(null), + }); + } + + const columns = createRoleColumns({ + handleEdit: (role) => { + window.location.href = roleEdit.url(role.id); + }, + handleDeleteClick: (role) => setDeleting(role), + }); + + return ( + <> + + +
+
+
+

+ Role & Permission +

+
+ +
+ + + + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Role" + description={`Apakah Anda yakin ingin menghapus role "${deleting?.name}"? Semua user dengan role ini akan kehilangan permission terkait.`} + confirmLabel="Hapus" + onConfirm={handleDelete} + /> +
+ + ); +} + +RoleIndex.layout = { + breadcrumbs: [ + { + title: 'Pengaturan', + href: '/admin/settings', + }, + { + title: 'Role & Permission', + href: rolesIndex.url(), + }, + ], +}; diff --git a/routes/web.php b/routes/web.php index 3d7d32a..c3df9ce 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,6 +14,7 @@ use App\Http\Controllers\Admin\HR\AttendanceController; use App\Http\Controllers\Admin\HR\EmployeeController; use App\Http\Controllers\Admin\HR\LeaveRequestController; +use App\Http\Controllers\Admin\RoleController; use Illuminate\Support\Facades\Route; Route::get('/', fn () => inertia('welcome', [ @@ -29,58 +30,67 @@ Route::inertia('dashboard', 'dashboard')->name('dashboard'); Route::prefix('admin/master')->name('admin.master.')->group(function () { - Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit']); - Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']); - Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']); + Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit'])->middleware('permission:category.view|category.create|category.update|category.delete'); + Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit'])->middleware('permission:supplier.view|supplier.create|supplier.update|supplier.delete'); + Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit'])->middleware('permission:customer.view|customer.create|customer.update|customer.delete'); }); Route::prefix('admin/finance')->name('admin.finance.')->group(function () { - Route::get('cash-accounts', [CashAccountController::class, 'index'])->name('cash-accounts.index'); - Route::post('cash-accounts/deposit', [CashAccountController::class, 'deposit'])->name('cash-accounts.deposit'); - Route::post('cash-accounts/withdrawal', [CashAccountController::class, 'withdrawal'])->name('cash-accounts.withdrawal'); - Route::put('cash-accounts/transactions/{transaction}', [CashAccountController::class, 'update'])->name('cash-accounts.transactions.update'); - Route::delete('cash-accounts/transactions/{transaction}', [CashAccountController::class, 'destroy'])->name('cash-accounts.transactions.destroy'); + Route::get('cash-accounts', [CashAccountController::class, 'index'])->name('cash-accounts.index')->middleware('permission:cash-account.view'); + Route::post('cash-accounts/deposit', [CashAccountController::class, 'deposit'])->name('cash-accounts.deposit')->middleware('permission:cash-account.deposit'); + Route::post('cash-accounts/withdrawal', [CashAccountController::class, 'withdrawal'])->name('cash-accounts.withdrawal')->middleware('permission:cash-account.withdrawal'); + Route::put('cash-accounts/transactions/{transaction}', [CashAccountController::class, 'update'])->name('cash-accounts.transactions.update')->middleware('permission:cash-account.update'); + Route::delete('cash-accounts/transactions/{transaction}', [CashAccountController::class, 'destroy'])->name('cash-accounts.transactions.destroy')->middleware('permission:cash-account.delete'); - Route::resource('expenses', ExpenseController::class)->except(['show', 'create', 'edit']); + Route::resource('expenses', ExpenseController::class)->except(['show', 'create', 'edit'])->middleware('permission:expense.view|expense.create|expense.update|expense.delete'); - Route::resource('employee-advances', EmployeeAdvanceController::class)->except(['show', 'create', 'edit']); - Route::post('employee-advances/{employeeAdvance}/approve', [EmployeeAdvanceController::class, 'approve'])->name('employee-advances.approve'); - Route::post('employee-advances/{employeeAdvance}/pay', [EmployeeAdvanceController::class, 'pay'])->name('employee-advances.pay'); + Route::resource('employee-advances', EmployeeAdvanceController::class)->except(['show', 'create', 'edit'])->middleware('permission:employee-advance.view|employee-advance.create|employee-advance.update|employee-advance.delete'); + Route::post('employee-advances/{employeeAdvance}/approve', [EmployeeAdvanceController::class, 'approve'])->name('employee-advances.approve')->middleware('permission:employee-advance.approve'); + Route::post('employee-advances/{employeeAdvance}/pay', [EmployeeAdvanceController::class, 'pay'])->name('employee-advances.pay')->middleware('permission:employee-advance.pay'); - Route::get('payroll-periods/current', [PayrollPeriodController::class, 'current'])->name('payroll-periods.current'); - Route::resource('payroll-periods', PayrollPeriodController::class)->only(['index', 'show']); - Route::post('payroll-periods/{payrollPeriod}/close', [PayrollPeriodController::class, 'close'])->name('payroll-periods.close'); - Route::post('payroll-periods/{payrollPeriod}/reopen', [PayrollPeriodController::class, 'reopen'])->name('payroll-periods.reopen'); + Route::get('payroll-periods/current', [PayrollPeriodController::class, 'current'])->name('payroll-periods.current')->middleware('permission:payroll-period.current'); + Route::resource('payroll-periods', PayrollPeriodController::class)->only(['index', 'show'])->middleware('permission:payroll-period.view'); + Route::post('payroll-periods/{payrollPeriod}/close', [PayrollPeriodController::class, 'close'])->name('payroll-periods.close')->middleware('permission:payroll-period.close'); + Route::post('payroll-periods/{payrollPeriod}/reopen', [PayrollPeriodController::class, 'reopen'])->name('payroll-periods.reopen')->middleware('permission:payroll-period.reopen'); - Route::post('payrolls/{payroll}/pay', [PayrollController::class, 'pay'])->name('payrolls.pay'); - Route::post('payrolls/{payroll}/cancel', [PayrollController::class, 'cancel'])->name('payrolls.cancel'); + Route::post('payrolls/{payroll}/pay', [PayrollController::class, 'pay'])->name('payrolls.pay')->middleware('permission:payroll.pay'); + Route::post('payrolls/{payroll}/cancel', [PayrollController::class, 'cancel'])->name('payrolls.cancel')->middleware('permission:payroll.cancel'); - Route::post('payrolls/{payroll}/adjustments', [PayrollAdjustmentController::class, 'store'])->name('payrolls.adjustments.store'); - Route::delete('payroll-adjustments/{payrollAdjustment}', [PayrollAdjustmentController::class, 'destroy'])->name('payroll-adjustments.destroy'); + Route::post('payrolls/{payroll}/adjustments', [PayrollAdjustmentController::class, 'store'])->name('payrolls.adjustments.store')->middleware('permission:payroll-adjustment.create'); + Route::delete('payroll-adjustments/{payrollAdjustment}', [PayrollAdjustmentController::class, 'destroy'])->name('payroll-adjustments.destroy')->middleware('permission:payroll-adjustment.delete'); }); Route::prefix('admin/settings')->name('admin.settings.')->group(function () { - Route::get('/', [AdminSettingsController::class, 'index'])->name('index'); - Route::put('system', [AdminSettingsController::class, 'updateSystem'])->name('update-system'); - Route::put('homepage', [AdminSettingsController::class, 'updateHomepage'])->name('update-homepage'); - Route::put('social-media', [AdminSettingsController::class, 'updateSocialMedia'])->name('update-social-media'); - Route::put('marketplace', [AdminSettingsController::class, 'updateMarketplace'])->name('update-marketplace'); - Route::put('hr', [AdminSettingsController::class, 'updateHR'])->name('update-hr'); + Route::get('/', [AdminSettingsController::class, 'index'])->name('index')->middleware('permission:settings.view'); + Route::put('system', [AdminSettingsController::class, 'updateSystem'])->name('update-system')->middleware('permission:settings.update-system'); + Route::put('homepage', [AdminSettingsController::class, 'updateHomepage'])->name('update-homepage')->middleware('permission:settings.update-homepage'); + Route::put('social-media', [AdminSettingsController::class, 'updateSocialMedia'])->name('update-social-media')->middleware('permission:settings.update-social-media'); + Route::put('marketplace', [AdminSettingsController::class, 'updateMarketplace'])->name('update-marketplace')->middleware('permission:settings.update-marketplace'); + Route::put('hr', [AdminSettingsController::class, 'updateHR'])->name('update-hr')->middleware('permission:settings.update-hr'); + }); + + Route::prefix('admin/settings')->name('admin.settings.')->group(function () { + Route::get('roles', [RoleController::class, 'index'])->name('roles.index')->middleware('permission:user.view'); + Route::get('roles/create', [RoleController::class, 'create'])->name('roles.create')->middleware('permission:user.create'); + Route::post('roles', [RoleController::class, 'store'])->name('roles.store')->middleware('permission:user.create'); + Route::get('roles/{role}/edit', [RoleController::class, 'edit'])->name('roles.edit')->middleware('permission:user.update'); + Route::put('roles/{role}', [RoleController::class, 'update'])->name('roles.update')->middleware('permission:user.update'); + Route::delete('roles/{role}', [RoleController::class, 'destroy'])->name('roles.destroy')->middleware('permission:user.delete'); }); Route::prefix('admin/hr')->name('admin.hr.')->group(function () { - Route::resource('employees', EmployeeController::class)->except(['show'])->parameters(['employees' => 'user']); - Route::post('employees/{user}/toggle-active', [EmployeeController::class, 'toggleActive'])->name('employees.toggle-active'); - Route::post('employees/{user}/reset-password', [EmployeeController::class, 'resetPassword'])->name('employees.reset-password'); + Route::resource('employees', EmployeeController::class)->except(['show'])->parameters(['employees' => 'user'])->middleware('permission:user.view|user.create|user.update|user.delete'); + Route::post('employees/{user}/toggle-active', [EmployeeController::class, 'toggleActive'])->name('employees.toggle-active')->middleware('permission:user.toggle-active'); + Route::post('employees/{user}/reset-password', [EmployeeController::class, 'resetPassword'])->name('employees.reset-password')->middleware('permission:user.reset-password'); - Route::resource('leave-requests', LeaveRequestController::class)->except(['show', 'create', 'edit']); - Route::post('leave-requests/{leaveRequest}/approve', [LeaveRequestController::class, 'approve'])->name('leave-requests.approve'); - Route::post('leave-requests/{leaveRequest}/reject', [LeaveRequestController::class, 'reject'])->name('leave-requests.reject'); + Route::resource('leave-requests', LeaveRequestController::class)->except(['show', 'create', 'edit'])->middleware('permission:leave-request.view|leave-request.create|leave-request.update|leave-request.delete'); + Route::post('leave-requests/{leaveRequest}/approve', [LeaveRequestController::class, 'approve'])->name('leave-requests.approve')->middleware('permission:leave-request.approve'); + Route::post('leave-requests/{leaveRequest}/reject', [LeaveRequestController::class, 'reject'])->name('leave-requests.reject')->middleware('permission:leave-request.reject'); - Route::get('attendances', [AttendanceController::class, 'index'])->name('attendances.index'); - Route::post('attendances', [AttendanceController::class, 'store'])->name('attendances.store'); - Route::put('attendances/{attendance}', [AttendanceController::class, 'update'])->name('attendances.update'); - Route::get('attendances/by-date', [AttendanceController::class, 'byDate'])->name('attendances.by-date'); + Route::get('attendances', [AttendanceController::class, 'index'])->name('attendances.index')->middleware('permission:attendance.view'); + Route::post('attendances', [AttendanceController::class, 'store'])->name('attendances.store')->middleware('permission:attendance.check-in'); + Route::put('attendances/{attendance}', [AttendanceController::class, 'update'])->name('attendances.update')->middleware('permission:attendance.check-out'); + Route::get('attendances/by-date', [AttendanceController::class, 'byDate'])->name('attendances.by-date')->middleware('permission:attendance.by-date'); }); }); diff --git a/tests/Feature/Admin/Settings/RoleTest.php b/tests/Feature/Admin/Settings/RoleTest.php new file mode 100644 index 0000000..381e15d --- /dev/null +++ b/tests/Feature/Admin/Settings/RoleTest.php @@ -0,0 +1,768 @@ +forgetCachedPermissions(); + + $modules = [ + 'user' => ['view', 'create', 'update', 'delete', 'toggle-active', 'reset-password'], + 'category' => ['view', 'create', 'update', 'delete'], + 'supplier' => ['view', 'create', 'update', 'delete'], + 'customer' => ['view', 'create', 'update', 'delete'], + 'cash-account' => ['view', 'deposit', 'withdrawal', 'update', 'delete'], + 'expense' => ['view', 'create', 'update', 'delete'], + 'employee-advance' => ['view', 'create', 'update', 'delete', 'approve', 'pay'], + 'payroll-period' => ['view', 'show', 'current', 'close', 'reopen'], + 'payroll' => ['pay', 'cancel'], + 'payroll-adjustment' => ['create', 'delete'], + 'leave-request' => ['view', 'create', 'update', 'delete', 'approve', 'reject'], + 'attendance' => ['view', 'check-in', 'check-out', 'by-date'], + 'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-marketplace', 'update-hr'], + ]; + + foreach ($modules as $module => $actions) { + foreach ($actions as $action) { + Permission::create(['name' => "{$module}.{$action}"]); + } + } + + $allPermissions = Permission::all()->pluck('name')->toArray(); + + $developer = Role::create(['name' => 'Developer']); + $developer->syncPermissions($allPermissions); +}); + +function createUserWithRole(string $roleName = 'Developer'): User +{ + $user = User::factory()->create(); + $user->assignRole($roleName); + + return $user; +} + +/* +|-------------------------------------------------------------------------- +| AUTHENTICATION +|-------------------------------------------------------------------------- +*/ + +test('guests are redirected to the login page', function () { + $response = $this->get(route('admin.settings.roles.index')); + $response->assertRedirect(route('login')); +}); + +test('authenticated users without permission cannot visit the role index page', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.settings.roles.index')); + $response->assertForbidden(); +}); + +test('authenticated users with permission can visit the role index page', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->get(route('admin.settings.roles.index')); + $response->assertOk(); +}); + +/* +|-------------------------------------------------------------------------- +| INDEX PAGE +|-------------------------------------------------------------------------- +*/ + +test('role index page displays roles', function () { + $this->actingAs(createUserWithRole()); + + Role::create(['name' => 'Manager']); + Role::create(['name' => 'Staff']); + + $response = $this->get(route('admin.settings.roles.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/roles/index') + ->has('roles', 3) + ); +}); + +test('index page works with zero additional roles', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->get(route('admin.settings.roles.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/roles/index') + ->has('roles', 1) + ); +}); + +test('index page displays correct role count after delete', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'To Delete']); + + $this->delete(route('admin.settings.roles.destroy', $role)); + + $response = $this->get(route('admin.settings.roles.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/roles/index') + ->has('roles', 1) + ); +}); + +/* +|-------------------------------------------------------------------------- +| CREATE / STORE +|-------------------------------------------------------------------------- +*/ + +test('authenticated users without permission cannot visit the create page', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.settings.roles.create')); + $response->assertForbidden(); +}); + +test('authenticated users with permission can visit the create page', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->get(route('admin.settings.roles.create')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/roles/create') + ->has('permissions') + ); +}); + +test('role can be created', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 'Manager', + 'permissions' => ['user.view', 'user.create'], + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.settings.roles.index')); + + $this->assertDatabaseHas('roles', ['name' => 'Manager']); +}); + +test('role name is required', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => '', + 'permissions' => ['user.view'], + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('role name must be string', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 12345, + 'permissions' => ['user.view'], + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('role name must not exceed 100 characters', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => str_repeat('a', 101), + 'permissions' => ['user.view'], + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('role name exactly 100 characters passes validation', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => str_repeat('a', 100), + 'permissions' => ['user.view'], + ]); + + $response->assertSessionHasNoErrors(); +}); + +test('role name must be unique', function () { + $this->actingAs(createUserWithRole()); + + Role::create(['name' => 'Existing Role']); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 'Existing Role', + 'permissions' => ['user.view'], + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('role name uniqueness is case sensitive', function () { + $this->actingAs(createUserWithRole()); + + Role::create(['name' => 'Manager']); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 'Manager', + 'permissions' => ['user.view'], + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('role name with special characters is accepted', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 'Admin & Staff!', + 'permissions' => ['user.view'], + ]); + + $response->assertSessionHasNoErrors(); + $this->assertDatabaseHas('roles', ['name' => 'Admin & Staff!']); +}); + +test('permissions are required', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 'New Role', + ]); + + $response->assertSessionHasErrors('permissions'); +}); + +test('permissions must be an array', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 'New Role', + 'permissions' => 'user.view', + ]); + + $response->assertSessionHasErrors('permissions'); +}); + +test('permissions must exist in database', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 'New Role', + 'permissions' => ['nonexistent.permission'], + ]); + + $response->assertSessionHasErrors('permissions.0'); +}); + +test('role can be created with multiple permissions', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 'Multi Permission Role', + 'permissions' => ['user.view', 'user.create', 'category.view'], + ]); + + $response->assertSessionHasNoErrors(); + + $role = Role::where('name', 'Multi Permission Role')->first(); + expect($role->permissions->pluck('name')->sort()->values()->toArray())->toEqual(['category.view', 'user.create', 'user.view']); +}); + +test('role can be created with empty permissions array', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 'No Permission Role', + 'permissions' => [], + ]); + + $response->assertSessionHasNoErrors(); + + $role = Role::where('name', 'No Permission Role')->first(); + expect($role->permissions->count())->toBe(0); +}); + +test('store flashes success toast via Inertia', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 'Toast Role', + 'permissions' => ['user.view'], + ]); + + $response->assertRedirect(); +}); + +test('store creates new role in database', function () { + $this->actingAs(createUserWithRole()); + + $this->post(route('admin.settings.roles.store'), [ + 'name' => 'Database Check', + 'permissions' => ['user.view'], + ]); + + $this->assertDatabaseCount('roles', 2); + $this->assertDatabaseHas('roles', ['name' => 'Database Check']); +}); + +test('multiple roles can be created sequentially', function () { + $this->actingAs(createUserWithRole()); + + $this->post(route('admin.settings.roles.store'), ['name' => 'Role 1', 'permissions' => ['user.view']]); + $this->post(route('admin.settings.roles.store'), ['name' => 'Role 2', 'permissions' => ['user.view']]); + $this->post(route('admin.settings.roles.store'), ['name' => 'Role 3', 'permissions' => ['user.view']]); + + $this->assertDatabaseCount('roles', 4); + $this->assertDatabaseHas('roles', ['name' => 'Role 1']); + $this->assertDatabaseHas('roles', ['name' => 'Role 2']); + $this->assertDatabaseHas('roles', ['name' => 'Role 3']); +}); + +/* +|-------------------------------------------------------------------------- +| EDIT / UPDATE +|-------------------------------------------------------------------------- +*/ + +test('authenticated users without permission cannot visit the edit page', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $role = Role::create(['name' => 'To Edit']); + + $response = $this->get(route('admin.settings.roles.edit', $role)); + $response->assertForbidden(); +}); + +test('authenticated users with permission can visit the edit page', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'To Edit']); + + $response = $this->get(route('admin.settings.roles.edit', $role)); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/roles/edit') + ->has('role') + ->has('permissions') + ); +}); + +test('role can be updated', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'Old Name']); + $role->syncPermissions(['user.view']); + + $response = $this->put(route('admin.settings.roles.update', $role), [ + 'name' => 'New Name', + 'permissions' => ['user.view', 'user.create'], + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.settings.roles.index')); + + $role->refresh(); + expect($role->name)->toBe('New Name'); + expect($role->permissions->pluck('name')->sort()->values()->toArray())->toEqual(['user.create', 'user.view']); +}); + +test('role name can be updated to itself', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'Same Name']); + + $response = $this->put(route('admin.settings.roles.update', $role), [ + 'name' => 'Same Name', + 'permissions' => ['user.view'], + ]); + + $response->assertSessionHasNoErrors(); +}); + +test('role update name must be unique excluding itself', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'First']); + Role::create(['name' => 'Second']); + + $response = $this->put(route('admin.settings.roles.update', $role), [ + 'name' => 'Second', + 'permissions' => ['user.view'], + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('role update name is required', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'Existing']); + + $response = $this->put(route('admin.settings.roles.update', $role), [ + 'name' => '', + 'permissions' => ['user.view'], + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('role update name must not exceed 100 characters', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'Existing']); + + $response = $this->put(route('admin.settings.roles.update', $role), [ + 'name' => str_repeat('a', 101), + 'permissions' => ['user.view'], + ]); + + $response->assertSessionHasErrors('name'); +}); + +test('role permissions can be updated', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'Update Perm']); + $role->syncPermissions(['user.view']); + + $this->put(route('admin.settings.roles.update', $role), [ + 'name' => 'Update Perm', + 'permissions' => ['category.view', 'category.create'], + ]); + + $role->refresh(); + expect($role->permissions->pluck('name')->sort()->values()->toArray())->toEqual(['category.create', 'category.view']); +}); + +test('role can be updated multiple times', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'Version 1']); + + $this->put(route('admin.settings.roles.update', $role), ['name' => 'Version 2', 'permissions' => ['user.view']]); + $role->refresh(); + expect($role->name)->toBe('Version 2'); + + $this->put(route('admin.settings.roles.update', $role), ['name' => 'Version 3', 'permissions' => ['user.view']]); + $role->refresh(); + expect($role->name)->toBe('Version 3'); +}); + +test('update flashes success toast via Inertia', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'Toast']); + + $response = $this->put(route('admin.settings.roles.update', $role), [ + 'name' => 'Updated Toast', + 'permissions' => ['user.view'], + ]); + + $response->assertRedirect(); +}); + +test('updating non-existent role returns 404', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->put(route('admin.settings.roles.update', 999999), [ + 'name' => 'Ghost Role', + 'permissions' => ['user.view'], + ]); + + $response->assertStatus(404); +}); + +/* +|-------------------------------------------------------------------------- +| DELETE / DESTROY +|-------------------------------------------------------------------------- +*/ + +test('authenticated users without permission cannot delete roles', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $role = Role::create(['name' => 'To Delete']); + + $response = $this->delete(route('admin.settings.roles.destroy', $role)); + $response->assertForbidden(); + + $this->assertDatabaseHas('roles', ['id' => $role->id]); +}); + +test('role can be deleted', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'To Delete']); + + $response = $this->delete(route('admin.settings.roles.destroy', $role)); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.settings.roles.index')); + + $this->assertDatabaseMissing('roles', ['id' => $role->id]); +}); + +test('role permissions are removed when role is deleted', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'With Perms']); + $role->syncPermissions(['user.view', 'user.create']); + + $this->delete(route('admin.settings.roles.destroy', $role)); + + $this->assertDatabaseMissing('roles', ['id' => $role->id]); + $this->assertDatabaseMissing('role_has_permissions', ['role_id' => $role->id]); +}); + +test('delete flashes success toast via Inertia', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'Toast Delete']); + + $response = $this->delete(route('admin.settings.roles.destroy', $role)); + + $response->assertRedirect(); +}); + +test('deleting non-existent role returns 404', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->delete(route('admin.settings.roles.destroy', 999999)); + + $response->assertStatus(404); +}); + +/* +|-------------------------------------------------------------------------- +| AUTHORIZATION - GUEST CANNOT PERFORM ACTIONS +|-------------------------------------------------------------------------- +*/ + +test('guest cannot create role', function () { + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 'Unauthorized', + 'permissions' => ['user.view'], + ]); + + $response->assertRedirect(route('login')); + $this->assertDatabaseMissing('roles', ['name' => 'Unauthorized']); +}); + +test('guest cannot update role', function () { + $role = Role::create(['name' => 'Existing']); + + $response = $this->put(route('admin.settings.roles.update', $role), [ + 'name' => 'Unauthorized Update', + 'permissions' => ['user.view'], + ]); + + $response->assertRedirect(route('login')); +}); + +test('guest cannot delete role', function () { + $role = Role::create(['name' => 'Existing']); + + $response = $this->delete(route('admin.settings.roles.destroy', $role)); + + $response->assertRedirect(route('login')); + $this->assertDatabaseHas('roles', ['id' => $role->id]); +}); + +/* +|-------------------------------------------------------------------------- +| AUTHORIZATION - USER WITHOUT ROLE CANNOT PERFORM ACTIONS +|-------------------------------------------------------------------------- +*/ + +test('user without developer role cannot create role', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.settings.roles.store'), [ + 'name' => 'Unauthorized', + 'permissions' => ['user.view'], + ]); + + $response->assertForbidden(); + $this->assertDatabaseMissing('roles', ['name' => 'Unauthorized']); +}); + +test('user without developer role cannot update role', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $role = Role::create(['name' => 'Existing']); + + $response = $this->put(route('admin.settings.roles.update', $role), [ + 'name' => 'Unauthorized Update', + 'permissions' => ['user.view'], + ]); + + $response->assertForbidden(); +}); + +test('user without developer role cannot delete role', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $role = Role::create(['name' => 'Existing']); + + $response = $this->delete(route('admin.settings.roles.destroy', $role)); + + $response->assertForbidden(); + $this->assertDatabaseHas('roles', ['id' => $role->id]); +}); + +/* +|-------------------------------------------------------------------------- +| DATA INTEGRITY +|-------------------------------------------------------------------------- +*/ + +test('created role has correct timestamps', function () { + $this->actingAs(createUserWithRole()); + + $this->post(route('admin.settings.roles.store'), [ + 'name' => 'Timestamp Role', + 'permissions' => ['user.view'], + ]); + + $role = Role::where('name', 'Timestamp Role')->first(); + expect($role->created_at)->not->toBeNull(); + expect($role->updated_at)->not->toBeNull(); +}); + +/* +|-------------------------------------------------------------------------- +| PERMISSION ASSIGNMENT +|-------------------------------------------------------------------------- +*/ + +test('role permissions are correctly synced', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'Sync Test']); + $role->syncPermissions(['user.view', 'user.create', 'category.view']); + + $role->refresh(); + expect($role->permissions->pluck('name')->sort()->values()->toArray())->toEqual(['category.view', 'user.create', 'user.view']); +}); + +test('role permissions can be replaced', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'Replace Test']); + $role->syncPermissions(['user.view']); + + $role->syncPermissions(['category.view', 'category.create']); + $role->refresh(); + + expect($role->permissions->pluck('name')->sort()->values()->toArray())->toEqual(['category.create', 'category.view']); +}); + +test('role can have all permissions', function () { + $this->actingAs(createUserWithRole()); + + $allPermissions = Permission::pluck('name')->toArray(); + + $role = Role::create(['name' => 'Super Admin']); + $role->syncPermissions($allPermissions); + + $role->refresh(); + expect($role->permissions->count())->toBe(count($allPermissions)); +}); + +/* +|-------------------------------------------------------------------------- +| REALISTIC USER SCENARIOS +|-------------------------------------------------------------------------- +*/ + +test('user creates role then immediately edits it', function () { + $this->actingAs(createUserWithRole()); + + $this->post(route('admin.settings.roles.store'), [ + 'name' => 'Draft Role', + 'permissions' => ['user.view'], + ]); + + $role = Role::where('name', 'Draft Role')->first(); + + $this->put(route('admin.settings.roles.update', $role), [ + 'name' => 'Final Role', + 'permissions' => ['user.view', 'user.create'], + ]); + + $role->refresh(); + expect($role->name)->toBe('Final Role'); + expect($role->permissions->pluck('name')->sort()->values()->toArray())->toEqual(['user.create', 'user.view']); +}); + +test('user creates multiple roles and deletes one', function () { + $this->actingAs(createUserWithRole()); + + $this->post(route('admin.settings.roles.store'), ['name' => 'Keep Role', 'permissions' => ['user.view']]); + $this->post(route('admin.settings.roles.store'), ['name' => 'Delete Role', 'permissions' => ['user.view']]); + $this->post(route('admin.settings.roles.store'), ['name' => 'Also Keep Role', 'permissions' => ['user.view']]); + + $toDelete = Role::where('name', 'Delete Role')->first(); + $this->delete(route('admin.settings.roles.destroy', $toDelete)); + + $response = $this->get(route('admin.settings.roles.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/roles/index') + ->has('roles', 3) + ); +}); + +test('user tries to create role without submitting any data', function () { + $this->actingAs(createUserWithRole()); + + $response = $this->post(route('admin.settings.roles.store'), []); + + $response->assertSessionHasErrors(['name', 'permissions']); +}); + +test('user tries to update role without submitting any data', function () { + $this->actingAs(createUserWithRole()); + + $role = Role::create(['name' => 'Existing']); + + $response = $this->put(route('admin.settings.roles.update', $role), []); + + $response->assertSessionHasErrors(['name', 'permissions']); +}); + +test('user rapidly submits same role creation twice', function () { + $this->actingAs(createUserWithRole()); + + $this->post(route('admin.settings.roles.store'), ['name' => 'Rapid Submit', 'permissions' => ['user.view']]); + $response = $this->post(route('admin.settings.roles.store'), ['name' => 'Rapid Submit', 'permissions' => ['user.view']]); + + $response->assertSessionHasErrors('name'); + $this->assertDatabaseCount('roles', 2); +});