feat: implement role and permission management with CRUD functionality

This commit is contained in:
Yoga Pangestu 2026-08-30 23:47:58 +07:00
parent acda85b4e5
commit 23961969f4
9 changed files with 436 additions and 60 deletions

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Admin\Developer;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Developer\UpdateRolePermissionsRequest;
use App\Services\Admin\Developer\RolePermissionService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\Permission\Models\Role;
class RolePermissionController extends Controller
{
public function __construct(
private readonly RolePermissionService $service,
) {}
public function index(): Response
{
return Inertia::render('admin/developer/roles/index', [
'roles' => $this->service->roles(),
'permissionGroups' => $this->service->groupedPermissions(),
]);
}
public function update(UpdateRolePermissionsRequest $request, Role $role): RedirectResponse
{
$this->service->updatePermissions($role, $request->validated('permissions'));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Permission role berhasil diperbarui.']);
return to_route('admin.developer.roles.index');
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Admin\Developer;
use App\Support\PermissionCatalog;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateRolePermissionsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'permissions' => ['present', 'array'],
'permissions.*' => ['string', Rule::in(PermissionCatalog::all())],
];
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Services\Admin\Developer;
use App\Support\PermissionCatalog;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class RolePermissionService
{
/**
* @return array<string, array<int, string>>
*/
public function groupedPermissions(): array
{
return PermissionCatalog::groups();
}
/**
* @return Collection<int, array{id: int, name: string, permissions: array<int, string>}>
*/
public function roles(): Collection
{
return Role::with('permissions:id,name')
->orderBy('name')
->get(['id', 'name'])
->map(fn (Role $role) => [
'id' => $role->id,
'name' => $role->name,
'permissions' => $role->permissions->pluck('name')->all(),
]);
}
/**
* @param array<int, string> $permissionNames
*/
public function updatePermissions(Role $role, array $permissionNames): Role
{
$valid = array_values(array_intersect($permissionNames, PermissionCatalog::all()));
if ($role->name === 'developer') {
$valid = array_values(array_unique([...$valid, 'view-roles', 'update-roles']));
}
$role->syncPermissions($valid);
app(PermissionRegistrar::class)->forgetCachedPermissions();
return $role->load('permissions:id,name');
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Support;
class PermissionCatalog
{
public const DASHBOARD = ['view-dashboard'];
public const MASTER = [
'view-academic-terms', 'create-academic-terms', 'update-academic-terms', 'delete-academic-terms', 'update-academic-terms-status',
'view-departments', 'create-departments', 'update-departments', 'delete-departments',
'view-courses', 'create-courses', 'update-courses', 'delete-courses',
];
public const ACADEMIC_CLASSES = [
'view-materials', 'create-materials', 'update-materials', 'delete-materials',
'view-assignments', 'create-assignments', 'update-assignments', 'delete-assignments',
'view-assignment-submissions', 'create-assignment-submissions', 'update-assignment-submissions', 'delete-assignment-submissions',
'view-schedules', 'create-schedules', 'update-schedules', 'delete-schedules',
'view-attendances', 'create-attendances', 'delete-attendances',
];
public const MANAGE = [
'view-course-classes', 'create-course-classes', 'update-course-classes', 'delete-course-classes',
'view-course-class-enrollments', 'create-course-class-enrollments', 'delete-course-class-enrollments',
'view-course-registrations', 'create-course-registrations', 'approve-course-registrations', 'reject-course-registrations',
'view-announcements', 'create-announcements', 'update-announcements', 'delete-announcements',
];
public const FINANCES = [
'view-tuition-invoices', 'create-tuition-invoices', 'update-tuition-invoices', 'delete-tuition-invoices',
'view-tuition-payments', 'create-tuition-payments', 'update-tuition-payments', 'delete-tuition-payments',
];
public const SERVICES = [
'view-letter-requests', 'create-letter-requests', 'update-letter-requests', 'delete-letter-requests',
'view-academic-advising-logs', 'create-academic-advising-logs', 'update-academic-advising-logs', 'delete-academic-advising-logs',
];
public const USERS = [
'view-lecturers', 'create-lecturers', 'update-lecturers', 'delete-lecturers', 'reset-lecturers-password', 'update-lecturers-status', 'export-lecturers',
'view-students', 'create-students', 'update-students', 'delete-students', 'reset-students-password', 'update-students-academic-status', 'update-students-account-status', 'export-students',
'view-administrators', 'create-administrators', 'update-administrators', 'delete-administrators', 'reset-administrators-password', 'update-administrators-status',
];
public const DEVELOPER = [
'view-logs',
'view-roles', 'update-roles',
];
/**
* @return array<string, array<int, string>>
*/
public static function groups(): array
{
return [
'Dashboard' => self::DASHBOARD,
'Master' => self::MASTER,
'Kelas' => self::ACADEMIC_CLASSES,
'Kelola' => self::MANAGE,
'Keuangan' => self::FINANCES,
'Layanan' => self::SERVICES,
'Pengguna' => self::USERS,
'Pengembang' => self::DEVELOPER,
];
}
/**
* @return array<int, string>
*/
public static function all(): array
{
return array_merge(...array_values(self::groups()));
}
}

View File

@ -2,6 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Support\PermissionCatalog;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
@ -13,57 +14,14 @@ public function run(): void
{ {
app()[PermissionRegistrar::class]->forgetCachedPermissions(); app()[PermissionRegistrar::class]->forgetCachedPermissions();
$master = [ $master = PermissionCatalog::MASTER;
'view-academic-terms', 'create-academic-terms', 'update-academic-terms', 'delete-academic-terms', 'update-academic-terms-status', $academicClasses = PermissionCatalog::ACADEMIC_CLASSES;
'view-departments', 'create-departments', 'update-departments', 'delete-departments', $manage = PermissionCatalog::MANAGE;
'view-courses', 'create-courses', 'update-courses', 'delete-courses', $finances = PermissionCatalog::FINANCES;
]; $services = PermissionCatalog::SERVICES;
$users = PermissionCatalog::USERS;
$academicClasses = [ $permissionNames = PermissionCatalog::all();
'view-materials', 'create-materials', 'update-materials', 'delete-materials',
'view-assignments', 'create-assignments', 'update-assignments', 'delete-assignments',
'view-assignment-submissions', 'create-assignment-submissions', 'update-assignment-submissions', 'delete-assignment-submissions',
'view-schedules', 'create-schedules', 'update-schedules', 'delete-schedules',
'view-attendances', 'create-attendances', 'delete-attendances',
];
$manage = [
'view-course-classes', 'create-course-classes', 'update-course-classes', 'delete-course-classes',
'view-course-class-enrollments', 'create-course-class-enrollments', 'delete-course-class-enrollments',
'view-course-registrations', 'create-course-registrations', 'approve-course-registrations', 'reject-course-registrations',
'view-announcements', 'create-announcements', 'update-announcements', 'delete-announcements',
];
$finances = [
'view-tuition-invoices', 'create-tuition-invoices', 'update-tuition-invoices', 'delete-tuition-invoices',
'view-tuition-payments', 'create-tuition-payments', 'update-tuition-payments', 'delete-tuition-payments',
];
$services = [
'view-letter-requests', 'create-letter-requests', 'update-letter-requests', 'delete-letter-requests',
'view-academic-advising-logs', 'create-academic-advising-logs', 'update-academic-advising-logs', 'delete-academic-advising-logs',
];
$users = [
'view-lecturers', 'create-lecturers', 'update-lecturers', 'delete-lecturers', 'reset-lecturers-password', 'update-lecturers-status', 'export-lecturers',
'view-students', 'create-students', 'update-students', 'delete-students', 'reset-students-password', 'update-students-academic-status', 'update-students-account-status', 'export-students',
'view-administrators', 'create-administrators', 'update-administrators', 'delete-administrators', 'reset-administrators-password', 'update-administrators-status',
];
$developer = [
'view-logs',
];
$permissionNames = [
'view-dashboard',
...$master,
...$academicClasses,
...$manage,
...$finances,
...$services,
...$users,
...$developer,
];
foreach ($permissionNames as $name) { foreach ($permissionNames as $name) {
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']); Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);

View File

@ -17,6 +17,7 @@ import {
Receipt, Receipt,
School, School,
ScrollText, ScrollText,
ShieldCheck,
User, User,
Users, Users,
} from 'lucide-react'; } from 'lucide-react';
@ -39,6 +40,7 @@ import { index as attendancesRoute } from '@/routes/admin/academic-classes/atten
import { index as materialsRoute } from '@/routes/admin/academic-classes/materials'; import { index as materialsRoute } from '@/routes/admin/academic-classes/materials';
import { index as schedulesRoute } from '@/routes/admin/academic-classes/schedules'; import { index as schedulesRoute } from '@/routes/admin/academic-classes/schedules';
import { index as logsRoute } from '@/routes/admin/developer/logs'; import { index as logsRoute } from '@/routes/admin/developer/logs';
import { index as rolePermissionsRoute } from '@/routes/admin/developer/roles';
import { index as feedbackRoute } from '@/routes/admin/feedback'; import { index as feedbackRoute } from '@/routes/admin/feedback';
import { index as tuitionInvoicesRoute } from '@/routes/admin/finances/tuition-invoices'; import { index as tuitionInvoicesRoute } from '@/routes/admin/finances/tuition-invoices';
import { index as announcementsRoute } from '@/routes/admin/manage/announcements'; import { index as announcementsRoute } from '@/routes/admin/manage/announcements';
@ -63,10 +65,12 @@ function buildNavMain({
isMahasiswa, isMahasiswa,
isDosen, isDosen,
canViewLogs, canViewLogs,
canViewRoles,
}: { }: {
isMahasiswa: boolean; isMahasiswa: boolean;
isDosen: boolean; isDosen: boolean;
canViewLogs: boolean; canViewLogs: boolean;
canViewRoles: boolean;
}): (NavGroup | NavItem)[] { }): (NavGroup | NavItem)[] {
return [ return [
{ {
@ -190,16 +194,29 @@ function buildNavMain({
}, },
], ],
}, },
...(canViewLogs ...(canViewLogs || canViewRoles
? [ ? [
{ {
label: 'Pengembang', label: 'Pengembang',
items: [ items: [
{ ...(canViewLogs
name: 'Logs', ? [
url: logsRoute.url(), {
icon: ScrollText, name: 'Logs',
}, url: logsRoute.url(),
icon: ScrollText,
},
]
: []),
...(canViewRoles
? [
{
name: 'Role & Permission',
url: rolePermissionsRoute.url(),
icon: ShieldCheck,
},
]
: []),
], ],
}, },
] ]
@ -227,7 +244,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const isMahasiswa = !isStaff && roleNames.includes('mahasiswa'); const isMahasiswa = !isStaff && roleNames.includes('mahasiswa');
const isDosen = !isStaff && roleNames.includes('dosen'); const isDosen = !isStaff && roleNames.includes('dosen');
const canViewLogs = (auth?.permissions ?? []).includes('view-logs'); const canViewLogs = (auth?.permissions ?? []).includes('view-logs');
const navMain = buildNavMain({ isMahasiswa, isDosen, canViewLogs }); const canViewRoles = (auth?.permissions ?? []).includes('view-roles');
const navMain = buildNavMain({
isMahasiswa,
isDosen,
canViewLogs,
canViewRoles,
});
return ( return (
<Sidebar collapsible="icon" {...props}> <Sidebar collapsible="icon" {...props}>

View File

@ -0,0 +1,186 @@
import { Head, router } from '@inertiajs/react';
import { Save } from 'lucide-react';
import { useState } from 'react';
import { PageHeader } from '@/components/page-header';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { update } from '@/routes/admin/developer/roles';
import type {
PermissionGroups,
RoleWithPermissions,
} from '@/types/role-permission';
import { formatRoleLabel } from '@/types/role-permission';
type Props = {
roles: RoleWithPermissions[];
permissionGroups: PermissionGroups;
};
export default function RolePermissionIndex({
roles,
permissionGroups,
}: Props) {
const [selectedRoleId, setSelectedRoleId] = useState<number | undefined>(
roles[0]?.id,
);
const selectedRole = roles.find((role) => role.id === selectedRoleId);
const [selected, setSelected] = useState<string[]>(
selectedRole?.permissions ?? [],
);
const [processing, setProcessing] = useState(false);
function handleSelectRole(id: string) {
const role = roles.find((r) => r.id === Number(id));
setSelectedRoleId(role?.id);
setSelected(role?.permissions ?? []);
}
function toggle(name: string, checked: boolean) {
setSelected((prev) =>
checked ? [...prev, name] : prev.filter((p) => p !== name),
);
}
function toggleGroup(names: string[], checked: boolean) {
setSelected((prev) => {
const withoutGroup = prev.filter((p) => !names.includes(p));
return checked ? [...withoutGroup, ...names] : withoutGroup;
});
}
function handleSave() {
if (!selectedRole) {
return;
}
setProcessing(true);
router.put(
update.url(selectedRole.id),
{ permissions: selected },
{
preserveScroll: true,
onFinish: () => setProcessing(false),
},
);
}
return (
<>
<Head title="Role & Permission" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Role & Permission"
actions={
selectedRole && (
<Button onClick={handleSave} disabled={processing}>
<Save className="h-4 w-4" />
Simpan
</Button>
)
}
/>
<div className="flex items-center gap-3">
<Select
value={
selectedRoleId ? String(selectedRoleId) : undefined
}
onValueChange={handleSelectRole}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue placeholder="Pilih role" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.id}
value={String(role.id)}
>
{formatRoleLabel(role.name)}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedRole && (
<Badge variant="secondary">
{selected.length} permission
</Badge>
)}
</div>
{selectedRole && (
<div className="grid gap-4">
{Object.entries(permissionGroups).map(
([group, names]) => {
const allChecked = names.every((name) =>
selected.includes(name),
);
return (
<Card key={group}>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="text-base">
{group}
</CardTitle>
<label className="flex items-center gap-2 text-sm font-normal text-muted-foreground">
<Checkbox
checked={allChecked}
onCheckedChange={(
checked,
) =>
toggleGroup(
names,
checked === true,
)
}
/>
Pilih Semua
</label>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{names.map((name) => (
<label
key={name}
className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted"
>
<Checkbox
checked={selected.includes(
name,
)}
onCheckedChange={(
checked,
) =>
toggle(
name,
checked ===
true,
)
}
/>
<span className="font-mono text-xs">
{name}
</span>
</label>
))}
</CardContent>
</Card>
);
},
)}
</div>
)}
</div>
</>
);
}

View File

@ -0,0 +1,20 @@
export type RoleWithPermissions = {
id: number;
name: string;
permissions: string[];
};
export type PermissionGroups = Record<string, string[]>;
export const RoleLabels: Record<string, string> = {
mahasiswa: 'Mahasiswa',
dosen: 'Dosen',
'staff-admin': 'Staff Admin',
'staff-keuangan': 'Staff Keuangan',
kaprodi: 'Kaprodi',
developer: 'Developer',
};
export function formatRoleLabel(name: string): string {
return RoleLabels[name] ?? name;
}

View File

@ -6,6 +6,7 @@
use App\Http\Controllers\Admin\AcademicClasses\ScheduleController; use App\Http\Controllers\Admin\AcademicClasses\ScheduleController;
use App\Http\Controllers\Admin\AcademicClasses\SubmissionController; use App\Http\Controllers\Admin\AcademicClasses\SubmissionController;
use App\Http\Controllers\Admin\Developer\LogController; use App\Http\Controllers\Admin\Developer\LogController;
use App\Http\Controllers\Admin\Developer\RolePermissionController;
use App\Http\Controllers\Admin\FeedbackController; use App\Http\Controllers\Admin\FeedbackController;
use App\Http\Controllers\Admin\Finances\TuitionInvoiceController; use App\Http\Controllers\Admin\Finances\TuitionInvoiceController;
use App\Http\Controllers\Admin\Finances\TuitionPaymentController; use App\Http\Controllers\Admin\Finances\TuitionPaymentController;
@ -238,8 +239,11 @@
Route::patch('administrators/{user}/user-status', [AdministratorController::class, 'updateUserStatus'])->name('administrators.update_user_status')->middleware('permission:update-administrators-status'); Route::patch('administrators/{user}/user-status', [AdministratorController::class, 'updateUserStatus'])->name('administrators.update_user_status')->middleware('permission:update-administrators-status');
}); });
Route::prefix('admin/developer')->name('admin.developer.')->middleware('permission:view-logs')->group(function () { Route::prefix('admin/developer')->name('admin.developer.')->group(function () {
Route::get('logs', [LogController::class, 'index'])->name('logs.index'); Route::get('logs', [LogController::class, 'index'])->name('logs.index')->middleware('permission:view-logs');
Route::get('logs/download', [LogController::class, 'download'])->name('logs.download'); Route::get('logs/download', [LogController::class, 'download'])->name('logs.download')->middleware('permission:view-logs');
Route::get('roles', [RolePermissionController::class, 'index'])->name('roles.index')->middleware('permission:view-roles');
Route::put('roles/{role}', [RolePermissionController::class, 'update'])->name('roles.update')->middleware('permission:update-roles');
}); });
}); });