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.
This commit is contained in:
Yoga Pangestu 2026-07-31 23:15:55 +07:00
parent 67e5a6271f
commit e9237f857e
15 changed files with 1885 additions and 40 deletions

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\RoleRequest;
use App\Services\Admin\Settings\RoleService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\Permission\Models\Role;
class RoleController extends Controller
{
public function __construct(
private RoleService $service
) {}
public function index(): Response
{
return Inertia::render('admin/roles/index', [
'roles' => $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');
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RoleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$roleId = $this->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',
];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Services\Admin\Settings;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class RoleService
{
public function getAll(): Collection
{
return Role::withCount('permissions')->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();
}
}

View File

@ -7,12 +7,15 @@
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Request; 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__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__ . '/../routes/web.php',
api: __DIR__.'/../routes/api.php', api: __DIR__ . '/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__ . '/../routes/console.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
@ -23,9 +26,15 @@
HandleInertiaRequests::class, HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class, AddLinkHeadersForPreloadedAssets::class,
]); ]);
$middleware->alias([
'role' => RoleMiddleware::class,
'permission' => PermissionMiddleware::class,
'role_or_permission' => RoleOrPermissionMiddleware::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(), fn(Request $request) => $request->is('api/*') || $request->expectsJson(),
); );
})->create(); })->create();

219
config/permission.php Normal file
View File

@ -0,0 +1,219 @@
<?php
use Spatie\Permission\DefaultTeamResolver;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
return [
'models' => [
/*
* 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',
],
];

View File

@ -15,6 +15,7 @@ class DatabaseSeeder extends Seeder
public function run(): void public function run(): void
{ {
$this->call([ $this->call([
RolePermissionSeeder::class,
UserSeeder::class, UserSeeder::class,
CategorySeeder::class, CategorySeeder::class,
SupplierSeeder::class, SupplierSeeder::class,

View File

@ -0,0 +1,117 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class RolePermissionSeeder extends Seeder
{
public function run(): void
{
app()[\Spatie\Permission\PermissionRegistrar::class]->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);
}
}
}

View File

@ -26,6 +26,8 @@ public function run(): void
'gender' => Gender::MALE->value, 'gender' => Gender::MALE->value,
]); ]);
$developer->assignRole('Developer');
$owner = User::factory()->create([ $owner = User::factory()->create([
'email' => 'owner@gmail.com', 'email' => 'owner@gmail.com',
'username' => 'owner', 'username' => 'owner',
@ -37,6 +39,8 @@ public function run(): void
'full_name' => 'Owner', 'full_name' => 'Owner',
'gender' => Gender::MALE->value, 'gender' => Gender::MALE->value,
]); ]);
$owner->assignRole('Owner');
}); });
} }
} }

View File

@ -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 categoriesIndex } from '@/routes/admin/master/categories';
import { index as customersIndex } from '@/routes/admin/master/customers'; import { index as customersIndex } from '@/routes/admin/master/customers';
import { index as suppliersIndex } from '@/routes/admin/master/suppliers'; import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
import { index as rolesIndex } from '@/routes/admin/settings/roles';
import { Link } from '@inertiajs/react'; import { Link } from '@inertiajs/react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { import {
@ -38,6 +39,7 @@ import {
RefreshCw, RefreshCw,
Scissors, Scissors,
Settings, Settings,
Shield,
ShoppingCart, ShoppingCart,
Tags, Tags,
Truck, Truck,
@ -90,6 +92,7 @@ const hrItems: NavMenuItem[] = [
const sistemItems: NavMenuItem[] = [ const sistemItems: NavMenuItem[] = [
{ title: 'Pengaturan', href: '/admin/settings', icon: Settings }, { title: 'Pengaturan', href: '/admin/settings', icon: Settings },
{ title: 'Role & Permission', href: rolesIndex.url(), icon: Shield },
{ title: 'Log Aktivitas', href: '#', icon: Activity }, { title: 'Log Aktivitas', href: '#', icon: Activity },
]; ];

View File

@ -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<Role>[] {
const { handleEdit, handleDeleteClick } = params;
return [
{
id: 'no',
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{row.index + 1}
</span>
),
meta: {
className: 'w-[50px] text-center',
headerClassName: 'w-[50px] text-center',
},
},
{
accessorKey: 'name',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Nama Role</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium">
{row.getValue('name') as string}
</span>
),
},
{
accessorKey: 'permissions_count',
header: () => <span className="block text-center">Jumlah Permission</span>,
meta: {
className: 'w-[180px] text-center',
headerClassName: 'w-[180px] text-center',
},
cell: ({ row }) => (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
{row.getValue('permissions_count') as number} permission
</span>
),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const role = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(role)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteClick(role)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
},
];
}

View File

@ -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<string, string[]>;
type Props = {
permissions: PermissionsByModule;
};
const moduleLabels: Record<string, string> = {
'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<string, string> = {
'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 (
<>
<Head title="Tambah Role" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Tambah Role
</h2>
</div>
<Button asChild variant="outline">
<a href={rolesIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Form action={store()}>
{({ errors, processing }) => (
<>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Role</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-2">
<Label htmlFor="name">
Nama Role <span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama role"
/>
<InputError message={errors.name} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Permission</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
{Object.entries(permissions).map(([module, actions]) => (
<div key={module} className="grid gap-3">
<Label className="text-sm font-semibold">
{moduleLabels[module] ?? module}
</Label>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
{actions.map((action) => (
<label
key={`${module}.${action}`}
className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted cursor-pointer"
>
<Checkbox
name="permissions[]"
value={`${module}.${action}`}
/>
<span className="text-xs">
{actionLabels[action] ?? action}
</span>
</label>
))}
</div>
</div>
))}
<InputError message={errors.permissions} />
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4 mt-6">
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}
RoleCreate.layout = {
breadcrumbs: [
{
title: 'Pengaturan',
href: '/admin/settings',
},
{
title: 'Role & Permission',
href: rolesIndex.url(),
},
{
title: 'Tambah',
href: '#',
},
],
};

View File

@ -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<string, string[]>;
type RoleData = {
id: number;
name: string;
permissions: {
id: number;
name: string;
}[];
};
type Props = {
role: RoleData;
permissions: PermissionsByModule;
};
const moduleLabels: Record<string, string> = {
'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<string, string> = {
'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 (
<>
<Head title="Edit Role" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Edit Role
</h2>
</div>
<Button asChild variant="outline">
<a href={rolesIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Form action={update(role.id)}>
{({ errors, processing }) => (
<>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Role</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-2">
<Label htmlFor="name">
Nama Role <span className="text-destructive">*</span>
</Label>
<Input
id="name"
name="name"
placeholder="Masukkan nama role"
defaultValue={role.name}
/>
<InputError message={errors.name} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Permission</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
{Object.entries(permissions).map(([module, actions]) => (
<div key={module} className="grid gap-3">
<Label className="text-sm font-semibold">
{moduleLabels[module] ?? module}
</Label>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
{actions.map((action) => (
<label
key={`${module}.${action}`}
className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted cursor-pointer"
>
<Checkbox
name="permissions[]"
value={`${module}.${action}`}
defaultChecked={assignedPermissions.includes(`${module}.${action}`)}
/>
<span className="text-xs">
{actionLabels[action] ?? action}
</span>
</label>
))}
</div>
</div>
))}
<InputError message={errors.permissions} />
</CardContent>
</Card>
</div>
<div className="flex items-center gap-4 mt-6">
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}
RoleEdit.layout = {
breadcrumbs: [
{
title: 'Pengaturan',
href: '/admin/settings',
},
{
title: 'Role & Permission',
href: rolesIndex.url(),
},
{
title: 'Edit',
href: '#',
},
],
};

View File

@ -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<Role | null>(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 (
<>
<Head title="Role & Permission" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
Role & Permission
</h2>
</div>
<Button asChild>
<a href={roleCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
</div>
<DataTable
columns={columns}
data={roles}
searchKey="name"
searchPlaceholder="Cari role..."
emptyText="Belum ada data role."
/>
<ConfirmDialog
open={deleting !== null}
onOpenChange={(open) => {
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}
/>
</div>
</>
);
}
RoleIndex.layout = {
breadcrumbs: [
{
title: 'Pengaturan',
href: '/admin/settings',
},
{
title: 'Role & Permission',
href: rolesIndex.url(),
},
],
};

View File

@ -14,6 +14,7 @@
use App\Http\Controllers\Admin\HR\AttendanceController; use App\Http\Controllers\Admin\HR\AttendanceController;
use App\Http\Controllers\Admin\HR\EmployeeController; use App\Http\Controllers\Admin\HR\EmployeeController;
use App\Http\Controllers\Admin\HR\LeaveRequestController; use App\Http\Controllers\Admin\HR\LeaveRequestController;
use App\Http\Controllers\Admin\RoleController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', fn () => inertia('welcome', [ Route::get('/', fn () => inertia('welcome', [
@ -29,58 +30,67 @@
Route::inertia('dashboard', 'dashboard')->name('dashboard'); Route::inertia('dashboard', 'dashboard')->name('dashboard');
Route::prefix('admin/master')->name('admin.master.')->group(function () { Route::prefix('admin/master')->name('admin.master.')->group(function () {
Route::resource('categories', CategoryController::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']); 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']); 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::prefix('admin/finance')->name('admin.finance.')->group(function () {
Route::get('cash-accounts', [CashAccountController::class, 'index'])->name('cash-accounts.index'); 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'); 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'); 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'); 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'); 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::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'); 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'); 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::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']); 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'); 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'); 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}/pay', [PayrollController::class, 'pay'])->name('payrolls.pay')->middleware('permission:payroll.pay');
Route::post('payrolls/{payroll}/cancel', [PayrollController::class, 'cancel'])->name('payrolls.cancel'); 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::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'); 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::prefix('admin/settings')->name('admin.settings.')->group(function () {
Route::get('/', [AdminSettingsController::class, 'index'])->name('index'); Route::get('/', [AdminSettingsController::class, 'index'])->name('index')->middleware('permission:settings.view');
Route::put('system', [AdminSettingsController::class, 'updateSystem'])->name('update-system'); Route::put('system', [AdminSettingsController::class, 'updateSystem'])->name('update-system')->middleware('permission:settings.update-system');
Route::put('homepage', [AdminSettingsController::class, 'updateHomepage'])->name('update-homepage'); 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'); 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'); Route::put('marketplace', [AdminSettingsController::class, 'updateMarketplace'])->name('update-marketplace')->middleware('permission:settings.update-marketplace');
Route::put('hr', [AdminSettingsController::class, 'updateHR'])->name('update-hr'); 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::prefix('admin/hr')->name('admin.hr.')->group(function () {
Route::resource('employees', EmployeeController::class)->except(['show'])->parameters(['employees' => 'user']); 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'); 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'); 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::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'); 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'); 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::get('attendances', [AttendanceController::class, 'index'])->name('attendances.index')->middleware('permission:attendance.view');
Route::post('attendances', [AttendanceController::class, 'store'])->name('attendances.store'); Route::post('attendances', [AttendanceController::class, 'store'])->name('attendances.store')->middleware('permission:attendance.check-in');
Route::put('attendances/{attendance}', [AttendanceController::class, 'update'])->name('attendances.update'); 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'); Route::get('attendances/by-date', [AttendanceController::class, 'byDate'])->name('attendances.by-date')->middleware('permission:attendance.by-date');
}); });
}); });

View File

@ -0,0 +1,768 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
uses(RefreshDatabase::class);
beforeEach(function () {
app()[\Spatie\Permission\PermissionRegistrar::class]->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);
});